'."\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 { ?>