* basename => [path, mode, owner, group] */ function backup_files() { // Each entry is [path, mode, owner, group]. The mode/owner/group // tuple is what the restore loop passes to `sudo install -m -o // -g`, and MUST match the corresponding /etc/sudoers.d/pistar- // dashboard PISTAR_DASH_INSTALL line for that destination — or // sudo will reject the install and the file will silently fail // to restore. Pre-2026-05-01 the restore loop used a hardcoded // `install -m 644 -o root -g root` for every entry, which // worked under the old NOPASSWD: ALL sudoers but breaks under // the path-scoped allowlist for: the .key files (require 600 // www-data:www-data so the dashboard's per-page sudo-less // parse_ini_file() reads still work — see fulledit_bmapikey.php // for the full rationale), wpa_supplicant.conf (600 root:root), // and the three system files (dhcpcd.conf, hosts, hostname) // which have no install entry in the deployed sudoers at all // and rely on a re-spun image to gain them. return array( // Network / WiFi 'dhcpcd.conf' => array('/etc/dhcpcd.conf', 644, 'root', 'root'), 'wpa_supplicant.conf' => array('/etc/wpa_supplicant/wpa_supplicant.conf', 600, 'root', 'root'), // Gateway daemon configs (flat key=value INI-ish) 'ircddbgateway' => array('/etc/ircddbgateway', 644, 'root', 'root'), 'mmdvmhost' => array('/etc/mmdvmhost', 644, 'root', 'root'), 'dstarrepeater' => array('/etc/dstarrepeater', 644, 'root', 'root'), 'dapnetgateway' => array('/etc/dapnetgateway', 644, 'root', 'root'), 'p25gateway' => array('/etc/p25gateway', 644, 'root', 'root'), 'm17gateway' => array('/etc/m17gateway', 644, 'root', 'root'), 'ysfgateway' => array('/etc/ysfgateway', 644, 'root', 'root'), 'ysf2dmr' => array('/etc/ysf2dmr', 644, 'root', 'root'), 'dgidgateway' => array('/etc/dgidgateway', 644, 'root', 'root'), 'nxdngateway' => array('/etc/nxdngateway', 644, 'root', 'root'), 'dmrgateway' => array('/etc/dmrgateway', 644, 'root', 'root'), 'mobilegps' => array('/etc/mobilegps', 644, 'root', 'root'), 'starnetserver' => array('/etc/starnetserver', 644, 'root', 'root'), 'timeserver' => array('/etc/timeserver', 644, 'root', 'root'), // Mode markers (operator has at most one of these) 'dstar-radio.mmdvmhost' => array('/etc/dstar-radio.mmdvmhost', 644, 'root', 'root'), 'dstar-radio.dstarrepeater' => array('/etc/dstar-radio.dstarrepeater', 644, 'root', 'root'), // Pi-Star service / dashboard config 'pistar-remote' => array('/etc/pistar-remote', 644, 'root', 'root'), 'hosts' => array('/etc/hosts', 644, 'root', 'root'), 'hostname' => array('/etc/hostname', 644, 'root', 'root'), // .key files: 600 www-data so unprivileged dashboard reads // (banner_warnings.inc, bm_links.php, bm_manager.php — all // parse_ini_file() without sudo) still work post-restore. 'bmapi.key' => array('/etc/bmapi.key', 600, 'www-data', 'www-data'), 'dapnetapi.key' => array('/etc/dapnetapi.key', 600, 'www-data', 'www-data'), 'pistar-css.ini' => array('/etc/pistar-css.ini', 644, 'root', 'root'), 'RSSI.dat' => array('/usr/local/etc/RSSI.dat', 644, 'root', 'root'), 'ircddblocal.php' => array('/var/www/dashboard/config/ircddblocal.php', 644, 'root', 'root'), 'config.php' => array('/var/www/dashboard/config/config.php', 644, 'root', 'root'), ); } // CSRF protection — see config/csrf.php for the full rationale. // Must run BEFORE any output: bootstraps the session on GET (so // Set-Cookie ships) and rejects forged POSTs cleanly with 403 // before the download / restore handlers run. // // CSRF protection here makes C1's zip-slip / restore-pipeline RCE // harder to exploit (attacker can no longer trigger restore via a // cross-site click); the underlying bugs in the restore handler // remain — they are tracked separately as the C1/C2 work-on-hold. csrf_verify(); // Layer 2 of the default-password protection — see config/banner_warnings.inc. // MUST run BEFORE any output so header('Location: ...') works. pistar_warnings_enforce_redirect(); // Load the language support require_once('config/language.php'); // Load the Pi-Star Release file $pistarReleaseConfig = '/etc/pistar-release'; $configPistarRelease = array(); $configPistarRelease = parse_ini_file($pistarReleaseConfig, true); // Load the Version Info require_once('config/version.php'); // Sanity Check that this file has been opened correctly if ($_SERVER["PHP_SELF"] == "/admin/config_backup.php") { // Sanity Check Passed. header('Cache-Control: no-cache'); // session_start() is no longer called here — csrf_verify() at // the top of the file already started the session via // csrf_session_start(). A second session_start() would emit a // "session is already active" NOTICE on PHP 8.x. ?> Pi-Star - <?php echo $lang['digital_voice']." ".$lang['dashboard']." - ".$lang['backup_restore'];?>
Pi-Star: /

Pi-Star

| | | |

'."\n"; if ( $_POST["action"] === "download" ) { echo "".$lang['backup_restore']."\n"; $output = "Finding config files to be backed up\n"; $backupDir = "/tmp/config_backup"; $backupZip = "/tmp/config_backup.zip"; $hostNameInfo = exec('cat /etc/hostname'); $output .= shell_exec("sudo rm -rf " . escapeshellarg($backupZip) . " 2>&1"); $output .= shell_exec("sudo rm -rf " . escapeshellarg($backupDir) . " 2>&1"); $output .= shell_exec("sudo mkdir " . escapeshellarg($backupDir) . " 2>&1"); // Iterate the canonical backup map so the backup and the // restore allowlist cannot drift from each other. dhcpcd.conf // is special-cased (only included when the operator has a // static IP configured) — everything else is unconditional; // missing source files are silently skipped (the cp returns // an error to stderr that nobody reads, same as the legacy // behaviour). foreach (backup_files() as $basename => $meta) { // $meta is [path, mode, owner, group]. The backup half // only needs the path — mode/owner/group are consumed // on the restore-half install command. $srcpath = $meta[0]; if ($basename === 'dhcpcd.conf') { // Only back up dhcpcd.conf if the operator has set a // static IP — otherwise restoring would clobber // working DHCP config on the target device. if (!shell_exec('cat /etc/dhcpcd.conf | grep "static ip_address" | grep -v "#"')) { continue; } } if (file_exists($srcpath)) { $output .= shell_exec( "sudo cp " . escapeshellarg($srcpath) . " " . escapeshellarg($backupDir . '/' . $basename) . " 2>&1" ); } } $output .= "Compressing backup files\n"; $output .= shell_exec("sudo zip -j " . escapeshellarg($backupZip) . " " . escapeshellarg($backupDir) . "/* 2>&1"); $output .= "Starting download\n"; echo "
"
             . htmlspecialchars($output, ENT_QUOTES, 'UTF-8') . "
\n"; if (file_exists($backupZip)) { $utc_time = gmdate('Y-m-d H:i:s'); $utc_tz = new DateTimeZone('UTC'); $local_tz = new DateTimeZone(date_default_timezone_get ()); $dt = new DateTime($utc_time, $utc_tz); $dt->setTimeZone($local_tz); $local_time = $dt->format('Y-M-d'); header('Content-Description: File Transfer'); header('Content-Type: application/octet-stream'); if ($hostNameInfo != "pi-star") { header('Content-Disposition: attachment; filename="'.basename("Pi-Star_Config_".$hostNameInfo."_".$local_time.".zip").'"'); } else { header('Content-Disposition: attachment; filename="'.basename("Pi-Star_Config_$local_time.zip").'"'); } header('Content-Transfer-Encoding: binary'); header('Expires: 0'); header('Cache-Control: must-revalidate'); header('Pragma: public'); header('Content-Length: ' . filesize($backupZip)); ob_clean(); flush(); readfile($backupZip); exit; } }; if ( $_POST["action"] === "restore" ) { echo "Config Restore\n"; $output = "Uploading your Config data\n"; // Wrap the whole restore in a do/while(false) so an early // bail-out (upload validation failure, ZIP parse error, // unsafe entry name, etc.) can break cleanly out of the // sequence without an `if/else` pyramid. PHP 7.0+ idiom. do { // Hardened restore pipeline. See the file-level docblock for // the security model. Constants here: // - $target_dir is hardcoded; the operator-supplied filename // never touches a path concat. // - $upload_path is also hardcoded; we move the uploaded // temp file to a known name before any further processing. // - $max_zip_bytes caps the upload; a real Pi-Star backup // is ~25 KB, nginx already caps the body at 512 KB, so // 256 KB is generous. $target_dir = '/tmp/config_restore'; $upload_path = '/tmp/config_restore_upload.zip'; $max_zip_bytes = 256 * 1024; $allowlist = backup_files(); shell_exec("sudo rm -rf " . escapeshellarg($target_dir) . " 2>&1"); shell_exec("rm -f " . escapeshellarg($upload_path) . " 2>&1"); shell_exec("mkdir -p " . escapeshellarg($target_dir) . " 2>&1"); // ----- Upload validation ------------------------------------- $upload_ok = false; $err_msg = ''; if (!isset($_FILES['fileToUpload']) || $_FILES['fileToUpload']['error'] !== UPLOAD_ERR_OK) { $err_msg = 'No file uploaded, or upload failed.'; } elseif ($_FILES['fileToUpload']['size'] <= 0 || $_FILES['fileToUpload']['size'] > $max_zip_bytes) { $err_msg = 'Upload size out of range (expected up to ' . round($max_zip_bytes / 1024) . ' KB).'; } else { $tmp_name = $_FILES['fileToUpload']['tmp_name']; // finfo_file() reads the magic bytes — much harder for an // attacker to fake than the client-supplied $_FILES['type']. $finfo = finfo_open(FILEINFO_MIME_TYPE); $magic = $finfo ? finfo_file($finfo, $tmp_name) : ''; if ($finfo) finfo_close($finfo); if ($magic !== 'application/zip') { $err_msg = 'Uploaded file is not a ZIP archive (detected ' . htmlspecialchars($magic, ENT_QUOTES, 'UTF-8') . ').'; } elseif (!move_uploaded_file($tmp_name, $upload_path)) { $err_msg = 'Could not stage upload to ' . $upload_path . '.'; } else { $upload_ok = true; } } if (!$upload_ok) { $output .= $err_msg . "\n"; echo "
"
                 . htmlspecialchars($output, ENT_QUOTES, 'UTF-8') . "
\n"; // Don't emit here — the outer code does it once // after the if-action blocks finish. The "Go Back" button // is rendered AFTER the table closes (see end of file). break; } // ----- ZIP entry validation --------------------------------- // Open the archive and decide which entries to extract BEFORE // any extraction happens. An entry is admitted iff: // - its name appears verbatim as a key in $allowlist // (basename → target map), AND // - its name contains no `/`, no `\`, and does not start // with `.` (defence in depth — the backup writer uses // `zip -j` so directory components are never legitimate). // Entries outside the allowlist are silently skipped — that's // the documented behaviour for forward-compat with future // Pi-Star versions adding new files. $zip = new ZipArchive(); if ($zip->open($upload_path) !== true) { $output .= "Could not open the uploaded ZIP.\n"; @unlink($upload_path); echo "
"
                 . htmlspecialchars($output, ENT_QUOTES, 'UTF-8') . "
\n"; break; } $admit = array(); for ($i = 0; $i < $zip->numFiles; $i++) { $name = $zip->getNameIndex($i); if ($name === false || $name === '') { continue; } if (strpos($name, '/') !== false || strpos($name, '\\') !== false || strpos($name, "\0") !== false || $name[0] === '.') { // Path-traversal / hidden / NUL-injection attempt. // Reject the whole archive — this is well outside any // Pi-Star-generated backup shape. break 2 exits the // for() AND the surrounding do/while(false) early- // exit block in one hop. $zip->close(); @unlink($upload_path); $output .= "Refusing ZIP — entry '" . htmlspecialchars(substr($name, 0, 64), ENT_QUOTES, 'UTF-8') . "' has an unsafe name.\n"; error_log("config_backup: rejected ZIP with unsafe entry '$name'"); echo "
"
                     . htmlspecialchars($output, ENT_QUOTES, 'UTF-8') . "
\n"; break 2; } if (isset($allowlist[$name])) { $admit[$name] = $i; } // else: silently ignored — unknown filename. } // Extract only the admitted entries into $target_dir. We use // ZipArchive::extractTo() with an explicit entry list so // ANYTHING outside that list is left in the archive and // never written to disk. $entries_to_extract = array_keys($admit); if (empty($entries_to_extract)) { $zip->close(); @unlink($upload_path); $output .= "ZIP contained no recognised Pi-Star config files.\n"; echo "
"
                 . htmlspecialchars($output, ENT_QUOTES, 'UTF-8') . "
\n"; break; } if (!$zip->extractTo($target_dir, $entries_to_extract)) { $zip->close(); @unlink($upload_path); $output .= "Failed to extract ZIP entries.\n"; echo "
"
                 . htmlspecialchars($output, ENT_QUOTES, 'UTF-8') . "
\n"; break; } $zip->close(); @unlink($upload_path); $output .= "Your .zip file was uploaded and unpacked (" . count($entries_to_extract) . " files).\n"; // ----- Service stop ---------------------------------------- $output .= "Stopping Services.\n"; $stop_services = array( 'cron.service', 'dstarrepeater.service', 'mmdvmhost.service', 'ircddbgateway.service', 'timeserver.service', 'pistar-watchdog.service', 'pistar-remote.service', 'ysfgateway.service', 'ysf2dmr.service', 'p25gateway.service', 'nxdngateway.service', 'm17gateway.service', 'dapnetgateway.service', 'mobilegps.service', ); foreach ($stop_services as $svc) { shell_exec('sudo systemctl stop ' . escapeshellarg($svc) . ' 2>&1'); } shell_exec('sudo mount -o remount,rw / 2>&1'); // ----- Per-file install ------------------------------------ // Replace the previous blind `sudo mv -v -f /tmp/.../* /etc/` // with a per-file `sudo install -m 644 -o root -g root` driven // by the canonical map. install is atomic on same-fs (rename) // and falls back to safe copy across filesystems. The mode and // owner are forced regardless of how the file arrived in the // archive. $output .= "Writing new Config\n"; // Tear down stale dstar-radio.* markers first — only one of // the two can be in use, and the restored archive may carry // a different mode than the device currently has. Use // `rm -rf` (not `rm -f`) so the call matches the // PISTAR_DASH_MARKERS sudoers entry — different argv tokens // yield different sudoers matches, and only `-rf` is // allowlisted. shell_exec('sudo rm -rf /etc/dstar-radio.* 2>&1'); $installed = 0; foreach ($admit as $basename => $_idx) { $src = $target_dir . '/' . $basename; $meta = $allowlist[$basename]; $target = $meta[0]; $mode = (string)$meta[1]; $owner = $meta[2]; $group = $meta[3]; if (!file_exists($src)) { // Should be impossible after a successful extractTo, // but guard anyway — extraction can fail per-entry on // unusual archives without throwing. continue; } // Per-file install args from backup_files() metadata — // mode/owner/group must match the deployed sudoers // PISTAR_DASH_INSTALL line for $target or sudo will // reject. The hardcoded `install -m 644 -o root -g root` // here used to silently fail on .key files (require // 600 www-data:www-data) and wpa_supplicant.conf // (600 root:root) under the path-scoped sudoers. $cmd = 'sudo install -m ' . $mode . ' -o ' . escapeshellarg($owner) . ' -g ' . escapeshellarg($group) . ' ' . escapeshellarg($src) . ' ' . escapeshellarg($target); $rc = 0; $cmd_out = array(); exec($cmd . ' 2>&1', $cmd_out, $rc); if ($rc === 0) { $installed++; } else { $output .= " install failed for $basename: " . implode(' / ', $cmd_out) . "\n"; } } $output .= "Installed $installed file(s).\n"; // ----- Post-restore re-applies (data-side, not shell) ----- // Timezone: the restored config.php contains the operator's // timezone in a date_default_timezone_set('…') call. We want // the OS clock to match the dashboard's view. PRE-fix this // path was a shell pipeline interpolating the grepped value; // post-fix we extract via PHP-side parsing and validate // strictly against PHP's own list of valid timezone IDs // before passing through escapeshellarg(). $cfg_path = '/var/www/dashboard/config/config.php'; if (is_readable($cfg_path)) { $cfg = file_get_contents($cfg_path); if (preg_match("/date_default_timezone_set\\(\\s*['\"]([^'\"]+)['\"]\\s*\\)/", $cfg, $m)) { $tz = $m[1]; if (in_array($tz, DateTimeZone::listIdentifiers(), true)) { shell_exec('sudo timedatectl set-timezone ' . escapeshellarg($tz) . ' 2>&1'); } else { error_log("config_backup: skipping unknown timezone '$tz' from restored config.php"); } } } // ircDDBGateway Remote Control password: the restored // /etc/ircddbgateway carries `remotePassword=...`; the // /root/.Remote Control sibling file (mode 600 root:root, // not in the backup set) needs the same value or the // remotecontrold tool can't reach the daemon. // // Pre-fix this was a shell-interpolated `sudo sed -i ...`. // Post-fix: read via PHP-side fopen/fgets (the file is mode // 644 root:root after we just installed it, so www-data can // read it back), validate, then route through the helper's // privileged-flat editor — same primitive the C6.7 fix uses // and also what the configure.php confPassword handler now // uses. No shell ever sees the value. $rp_target = '/etc/ircddbgateway'; if (is_readable($rp_target)) { $rp = ''; foreach (file($rp_target, FILE_IGNORE_NEW_LINES) as $line) { if (strpos($line, 'remotePassword=') === 0) { $rp = substr($line, strlen('remotePassword=')); break; } } // ircDDBGateway accepts arbitrary printable bytes for the // password; the helper's NUL/CR/LF guard is the floor. if ($rp !== '' && !preg_match('/[\x00\r\n]/', $rp)) { config_writer_stage_privileged_flat( '/root/.Remote Control', 'password', $rp ); config_writer_commit(false); } } shell_exec('sudo mount -o remount,ro / 2>&1'); // ----- Service start --------------------------------------- $output .= "Starting Services.\n"; $start_services = array( 'dstarrepeater.service', 'mmdvmhost.service', 'ircddbgateway.service', 'timeserver.service', 'pistar-watchdog.service', 'pistar-remote.service', ); foreach ($start_services as $svc) { shell_exec('sudo systemctl start ' . escapeshellarg($svc) . ' 2>&1'); } if (substr(exec('grep "pistar-upnp.service" /etc/crontab | cut -c 1'), 0, 1) !== '#') { shell_exec('sudo systemctl start pistar-upnp.service 2>&1'); } $start_services_after_upnp = array( 'ysfgateway.service', 'ysf2dmr.service', 'p25gateway.service', 'nxdngateway.service', 'm17gateway.service', 'dapnetgateway.service', 'mobilegps.service', 'cron.service', ); foreach ($start_services_after_upnp as $svc) { shell_exec('sudo systemctl start ' . escapeshellarg($svc) . ' 2>&1'); } // Cleanup: remove the staged extraction dir so subsequent // restores don't see leftovers. shell_exec("sudo rm -rf " . escapeshellarg($target_dir) . " 2>&1"); $output .= "Configuration Restore Complete.\n"; echo "
"
             . htmlspecialchars($output, ENT_QUOTES, 'UTF-8') . "
\n"; } while (false); // end do/while(false) early-exit block }; echo "\n"; } else { ?>
" method="post" enctype="multipart/form-data">
Download Configuration
Restore Configuration


WARNING:
Editing the files outside of Pi-Star *could* have un-desireable side effects.

This backup and restore tool, will backup your config files to a Zip file, and allow you to restore them later
either to this Pi-Star or another one.
  • System Passwords / Dashboard passwords are NOT backed up / restored.
  • Wireless Configuration IS backed up and restored