diff --git a/emhttp/auth-request.php b/emhttp/auth-request.php index b1c5ebea2d..dc21578877 100644 --- a/emhttp/auth-request.php +++ b/emhttp/auth-request.php @@ -14,8 +14,52 @@ session_write_close(); } -// Include JS caching functions -require_once '/usr/local/emhttp/webGui/include/JSCache.php'; +function isPathInDocroot(string $realPath, string $docroot): bool { + return $realPath === $docroot || str_starts_with($realPath, $docroot . '/'); +} + +function getCanonicalRequestUri(string $docroot): string { + $requestUri = getRequestUriPath(); + + $realRequestPath = realpath($docroot . '/' . ltrim($requestUri, '/')); + if (!is_string($realRequestPath) || !isPathInDocroot($realRequestPath, $docroot)) { + return ''; + } + + $canonicalRequestUri = substr($realRequestPath, strlen($docroot)); + return $canonicalRequestUri === '' ? '/' : $canonicalRequestUri; +} + +function isWebComponentsRequest(string $requestUri): bool { + $webComponentsDirectory = '/plugins/dynamix.my.servers/unraid-components'; + return $requestUri === $webComponentsDirectory || str_starts_with($requestUri, $webComponentsDirectory . '/'); +} + +function getRequestUriPath(): string { + $requestUri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); + return is_string($requestUri) ? $requestUri : '/'; +} + +function getAllowedExternalPublicAssetTargets(): array { + return [ + '/webGui/images/case-model.png' => '/boot/config/plugins/dynamix/case-model.png', + ]; +} + +function isAllowedPublicAssetRequest(string $requestUri, string $docroot, array $arrWhitelist): bool { + if (!in_array($requestUri, $arrWhitelist, true)) { + return false; + } + + $realRequestPath = realpath($docroot . '/' . ltrim($requestUri, '/')); + if (is_string($realRequestPath) && isPathInDocroot($realRequestPath, $docroot)) { + return true; + } + + $allowedExternalTargets = getAllowedExternalPublicAssetTargets(); + return isset($allowedExternalTargets[$requestUri]) && + $realRequestPath === $allowedExternalTargets[$requestUri]; +} // Base whitelist of files $arrWhitelist = [ @@ -54,12 +98,22 @@ '/manifest.json' ]; -// Whitelist ALL files from the unraid-components directory -$webComponentsDirectory = '/plugins/dynamix.my.servers/unraid-components/'; -$requestUri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) ?? '/'; +// Use canonical filesystem path checks against the trusted docroot. +$docroot = '/usr/local/emhttp'; +$requestUri = getRequestUriPath(); +$canonicalRequestUri = getCanonicalRequestUri($docroot); + +// Allow explicit public assets with strict target checks. +if (isAllowedPublicAssetRequest($requestUri, $docroot, $arrWhitelist)) { + http_response_code(200); + exit; +} -// Check if the request is for any file in the unraid-components directory -if (str_starts_with($requestUri, $webComponentsDirectory) || in_array($requestUri, $arrWhitelist)) { +// Allow canonical requests under unraid-components. +if ( + $canonicalRequestUri !== '' && + isWebComponentsRequest($canonicalRequestUri) +) { // authorized http_response_code(200); } else { diff --git a/emhttp/plugins/dynamix.docker.manager/javascript/docker.js b/emhttp/plugins/dynamix.docker.manager/javascript/docker.js index ad46fa3850..fd8599c337 100644 --- a/emhttp/plugins/dynamix.docker.manager/javascript/docker.js +++ b/emhttp/plugins/dynamix.docker.manager/javascript/docker.js @@ -51,6 +51,8 @@ function addDockerContainerContext(container, image, template, started, paused, } context.destroy('#'+id); context.attach('#'+id, opts); + $('#dropdown-'+id).css('z-index', 10001) + .append('
'); } function addDockerImageContext(image, imageTag) { var opts = []; diff --git a/emhttp/plugins/dynamix.plugin.manager/scripts/language b/emhttp/plugins/dynamix.plugin.manager/scripts/language index 0515669096..a448ff0366 100755 --- a/emhttp/plugins/dynamix.plugin.manager/scripts/language +++ b/emhttp/plugins/dynamix.plugin.manager/scripts/language @@ -86,6 +86,26 @@ function run($command) { return pclose($run); } +function remove_tree(string $dir): bool { + if (!is_dir($dir)) return false; + $items = scandir($dir); + if ($items === false) return false; + foreach ($items as $item) { + if ($item === '.' || $item === '..') continue; + $entry = "$dir/$item"; + if (is_dir($entry) && !is_link($entry)) { + if (!remove_tree($entry)) return false; + } elseif (!@unlink($entry)) { + return false; + } + } + return @rmdir($dir); +} + +function valid_language_pack_name($name): bool { + return is_string($name) && $name !== '' && preg_match('/^[A-Za-z0-9._$-]+$/', $name); +} + // Run hooked scripts before correct execution of "method" // method = install, update, remove, check // hook programs receives three parameters: type=language and method and language-name @@ -159,7 +179,11 @@ function language($method, $xml_file, &$error) { switch ($method) { case 'install': $url = $xml->LanguageURL; - $name = $xml->LanguagePack; + $name = (string)$xml->LanguagePack; + if (!valid_language_pack_name($name)) { + $error = "invalid language pack"; + return false; + } $save = "$boot/dynamix/lang-$name.zip"; if (!file_exists($save)) { if ($url) { @@ -173,22 +197,54 @@ function language($method, $xml_file, &$error) { } } $path = "$docroot/languages/$name"; - exec("mkdir -p $path"); + if (!is_dir($path) && !@mkdir($path, 0777, true)) { + $error = "failed to create language directory"; + return false; + } @unlink("$docroot/webGui/javascript/translate.$name.js"); - foreach (glob("$path/*.dot",GLOB_NOSORT) as $dot_file) unlink($dot_file); - exec("unzip -qqLjo -d $path $save", $dummy, $err); + foreach (['*.dot', '*.txt', '*.json'] as $pattern) { + foreach (glob("$path/$pattern", GLOB_NOSORT) as $lang_file) @unlink($lang_file); + } + $err = 0; + $zip = new ZipArchive(); + if ($zip->open($save) === true) { + for ($i = 0; $i < $zip->numFiles; $i++) { + $entry = $zip->getNameIndex($i); + if ($entry === false) continue; + if (substr($entry, -1) === '/') continue; + $content = $zip->getFromIndex($i); + if ($content === false) { + $err = 2; + break; + } + $entryName = strtolower(basename($entry)); + if ($entryName === '') continue; + if (!preg_match('/\.(dot|txt|json)$/', $entryName)) continue; + if (file_put_contents("$path/$entryName", $content) === false) { + $err = 2; + break; + } + } + $zip->close(); + } else { + $err = 2; + } if ($err > 1) { @unlink($save); - exec("rm -rf $path"); + remove_tree($path); $error = "unzip failed. Error code $err"; return false; } return true; case 'remove': - $name = $xml->LanguagePack; + $name = (string)$xml->LanguagePack; if ($name) { + if (!valid_language_pack_name($name)) { + $error = "invalid language pack"; + return false; + } $path = "$docroot/languages/$name"; - exec("rm -rf $path"); + if (is_dir($path)) remove_tree($path); @unlink("$docroot/webGui/javascript/translate.$name.js"); @unlink("$boot/lang-$name.xml"); @unlink("$plugins/lang-$name.xml"); diff --git a/emhttp/plugins/dynamix.vm.manager/VMMachines.page b/emhttp/plugins/dynamix.vm.manager/VMMachines.page index 69e450aa72..d937d6d4d5 100755 --- a/emhttp/plugins/dynamix.vm.manager/VMMachines.page +++ b/emhttp/plugins/dynamix.vm.manager/VMMachines.page @@ -503,7 +503,12 @@ function tableHeaderResize() { $(function() { $color = strpos($msg, "rror:")!==false ? 'red-text':'green-text'?> - $('#countdown').html("=_($msg)?>"); + $('#countdown').empty().append( + $('', { + class: '=$color?>', + text: =json_encode(_($msg), JSON_HEX_TAG|JSON_HEX_AMP|JSON_HEX_APOS|JSON_HEX_QUOT)?> + }) + ); $('#btnAddVM').click(function AddVMEvent(){$('.tab>input#tab2').click();}); $.removeCookie('lockbutton'); diff --git a/emhttp/plugins/dynamix/include/FileUpload.php b/emhttp/plugins/dynamix/include/FileUpload.php index 27aca0503a..80ff432075 100644 --- a/emhttp/plugins/dynamix/include/FileUpload.php +++ b/emhttp/plugins/dynamix/include/FileUpload.php @@ -21,6 +21,28 @@ $safeexts = ['.png']; $result = false; +function in_safe_path(string $path, string $base): bool { + $path = rtrim($path, '/').'/'; + $base = rtrim($base, '/').'/'; + return strpos($path, $base) === 0; +} + +function remove_tree(string $dir): bool { + if (!is_dir($dir)) return false; + $items = scandir($dir); + if ($items === false) return false; + foreach ($items as $item) { + if ($item === '.' || $item === '..') continue; + $entry = "$dir/$item"; + if (is_dir($entry) && !is_link($entry)) { + if (!remove_tree($entry)) return false; + } elseif (!@unlink($entry)) { + return false; + } + } + return @rmdir($dir); +} + require_once "$docroot/webGui/include/Helpers.php"; switch ($_POST['cmd'] ?? 'load') { @@ -47,20 +69,37 @@ case 'save': // move uploaded file ($verifiedPNG) to final destination $verifiedPNG = "$temp/".basename($file); - $path = $_POST['path']; + $path = $_POST['path'] ?? ''; + $outputRaw = $_POST['output'] ?? ''; + $output = basename($outputRaw); + $outputExt = strtolower(substr($output, -4)); + $isValidFilename = $output !== '' && $output === $outputRaw && preg_match('/^[A-Za-z0-9._$-]+$/', $output); foreach ($safepaths as $safepath) { - if (strpos(dirname("$path/{$_POST['output']}"),$safepath)===0 && in_array(substr(basename($_POST['output']),-4),$safeexts)) { - exec("mkdir -p ".escapeshellarg(realpath($path))); - $result = @rename($verifiedPNG, "$path/{$_POST['output']}"); + $safeBase = realpath($safepath); + $targetDir = realpath($path); + if (!$targetDir && $safeBase) { + $parentDir = realpath(dirname($path)); + if ($parentDir && in_safe_path($parentDir, $safeBase) && @mkdir($path, 0777, true)) { + $targetDir = realpath($path); + } + } + if ($targetDir && $safeBase && in_safe_path($targetDir, $safeBase) && $isValidFilename && in_array($outputExt, $safeexts, true)) { + if (is_dir($targetDir)) { + $result = @rename($verifiedPNG, "$targetDir/$output"); + } break; } } break; case 'delete': - $path = $_POST['path']; + $path = $_POST['path'] ?? ''; + $file = basename($file); + $targetFile = realpath("$path/$file"); + $targetExt = $targetFile ? strtolower(substr($targetFile, -4)) : ''; foreach ($safepaths as $safepath) { - if (strpos(realpath("$path/$file"), $safepath) === 0 && in_array(substr(realpath("$path/$file"), -4), $safeexts)) { - exec("rm -f ".escapeshellarg(realpath("$path/$file"))); + $safeBase = realpath($safepath); + if ($targetFile && $safeBase && in_safe_path($targetFile, $safeBase) && in_array($targetExt, $safeexts, true)) { + @unlink($targetFile); $result = true; break; } @@ -70,14 +109,37 @@ $file = basename($file); $path = "$docroot/languages/$file"; $save = "/tmp/lang-$file.zip"; - exec("mkdir -p $path"); + if (!is_dir($path) && !@mkdir($path, 0777, true)) break; if ($result = file_put_contents($save,base64_decode(preg_replace('/^data:.*;base64,/','',$_POST['filedata'])))) { @unlink("$docroot/webGui/javascript/translate.$file.js"); foreach (glob("$path/*.dot",GLOB_NOSORT) as $dot_file) unlink($dot_file); - exec("unzip -qqjLo -d $path $save", $dummy, $err); + $err = 0; + $zip = new ZipArchive(); + if ($zip->open($save) === true) { + for ($i = 0; $i < $zip->numFiles; $i++) { + $entry = $zip->getNameIndex($i); + if ($entry === false) continue; + if (substr($entry, -1) === '/') continue; + $content = $zip->getFromIndex($i); + if ($content === false) { + $err = 2; + break; + } + $name = strtolower(basename($entry)); + if ($name === '') continue; + if (!preg_match('/\.(dot|txt|json)$/', $name)) continue; + if (file_put_contents("$path/$name", $content) === false) { + $err = 2; + break; + } + } + $zip->close(); + } else { + $err = 2; + } @unlink($save); if ($err > 1) { - exec("rm -rf $path"); + remove_tree($path); $result = false; break; } @@ -101,7 +163,7 @@ $file = basename($file); $path = "$docroot/languages/$file"; if ($result = is_dir($path)) { - exec("rm -rf $path"); + $result = remove_tree($path); @unlink("$docroot/webGui/javascript/translate.$file.js"); @unlink("$boot/lang-$file.xml"); @unlink("$plugins/lang-$file.xml"); diff --git a/emhttp/plugins/dynamix/include/ToggleState.php b/emhttp/plugins/dynamix/include/ToggleState.php index 63e4e87dcd..d940e75169 100644 --- a/emhttp/plugins/dynamix/include/ToggleState.php +++ b/emhttp/plugins/dynamix/include/ToggleState.php @@ -19,7 +19,7 @@ $action = $_POST['action']??''; function emcmd($cmd) { - exec("emcmd '$cmd'"); + exec("emcmd ".escapeshellarg($cmd)); } switch ($device) { case 'New': diff --git a/emhttp/plugins/dynamix/scripts/monitor b/emhttp/plugins/dynamix/scripts/monitor index 01c25eb86a..1e56ca7634 100755 --- a/emhttp/plugins/dynamix/scripts/monitor +++ b/emhttp/plugins/dynamix/scripts/monitor @@ -42,6 +42,184 @@ $pools = pools_filter($disks); $errors = []; $top = 120; +function panelcontrol_monitor_has_show_flag() { + return true; +} + +function panelcontrol_monitor_state_rank($state) { + switch ((string)$state) { + case 'alert': return 5; + case 'warning': return 4; + case 'normal-blink': return 3; + case 'normal': return 2; + case 'off': return 1; + default: return 0; + } +} + +function panelcontrol_monitor_push_event(&$events, $event, $scope, $target, $state, $reason, $details=[]) { + $events[] = [ + 'event' => (string)$event, + 'scope' => (string)$scope, + 'target' => (string)$target, + 'state' => (string)$state, + 'reason' => (string)$reason, + 'details' => is_array($details) ? $details : [], + ]; +} + +function panelcontrol_monitor_build_payload($var, $disks, $devs, $saved, $display, $server, $high1, $high2, $top) { + $events = []; + + foreach ((array)$disks as $disk) { + $name = _var($disk,'name'); + if ($name === 'flash' || substr(_var($disk,'status'),-3) === '_NP') { + continue; + } + + $diskName = no_tilde((string)$name); + $diskDevice = strtolower(trim((string)_var($disk,'device',''))); + $diskId = trim((string)_var($disk,'id','')); + $temp = _var($disk,'temp','*'); + $spundownRaw = _var($disk,'spundown',''); + $spundownValue = strtolower(trim((string)$spundownRaw)); + $status = strtolower((string)_var($disk,'status','')); + $isNoDeviceStatus = strpos($status, 'np') !== false; + $isDisabledStatus = strpos($status, 'dsbl') !== false || strpos($status, 'disabled') !== false; + if (($diskDevice === '' && $diskId === '') || $isNoDeviceStatus || $isDisabledStatus) { + continue; + } + $target = $diskDevice !== '' ? $diskDevice : $diskId; + $color = strtolower((string)strtok(_var($disk,'color'),'-')); + $eventsBeforeDisk = count($events); + $isSpunDown = in_array($spundownValue, ['1', 'true', 'yes', 'on'], true) + || strpos($status, 'sby') !== false + || strpos($status, 'standby') !== false; + $identity = [ + 'disk_name' => $diskName, + 'disk_device' => $diskDevice, + 'disk_id' => $diskId, + 'disk_temp' => is_numeric($temp) ? (int)$temp : null, + 'disk_spundown' => $spundownRaw, + ]; + + if ($color === 'red') { + panelcontrol_monitor_push_event($events, 'monitor.storage.offline', 'disk', $target, 'alert', 'disk-error-state', [ + 'status' => $status, + ] + $identity); + } elseif ($color === 'yellow') { + panelcontrol_monitor_push_event($events, 'monitor.storage.activity', 'disk', $target, 'normal-blink', 'disk-rebuild-or-sync', [ + 'status' => $status, + ] + $identity); + } + + if ($isSpunDown) { + panelcontrol_monitor_push_event($events, 'monitor.storage.spindown', 'disk', $target, 'normal', 'disk-standby', [ + 'status' => $status, + ] + $identity); + } + + if (!$isSpunDown) { + if (is_numeric($temp)) { + panelcontrol_monitor_push_event($events, 'monitor.system.temperature', 'disk', $target, 'normal', 'temperature-sample', [ + 'temp' => (int)$temp, + ] + $identity); + } + + [$hotNVME,$maxNVME] = _var($disk,'transport')=='nvme' ? get_nvme_info(_var($disk,'device'),'temp') : [-1,-1]; + $hot = _var($disk,'hotTemp',-1)>=0 ? $disk['hotTemp'] : ($hotNVME>=0 ? $hotNVME : (_var($disk,'rotational',1)==0 && $display['hotssd']>=0 ? $display['hotssd'] : $display['hot'])); + $max = _var($disk,'maxTemp',-1)>=0 ? $disk['maxTemp'] : ($maxNVME>=0 ? $maxNVME : (_var($disk,'rotational',1)==0 && $display['maxssd']>=0 ? $display['maxssd'] : $display['max'])); + $tempState = exceed($temp,$max,$top) ? 'alert' : (exceed($temp,$hot,$top) ? 'warning' : ''); + if ($tempState !== '') { + panelcontrol_monitor_push_event($events, 'monitor.system.temperature', 'disk', $target, $tempState, 'temperature-threshold', [ + 'temp' => (int)$temp, + 'warning' => (int)$hot, + 'critical' => (int)$max, + ] + $identity); + } + } + + $numErrors = (int)_var($disk,'numErrors',0); + if ($numErrors > 0) { + panelcontrol_monitor_push_event($events, 'monitor.storage.health', 'disk', $target, 'alert', 'read-errors', [ + 'errors' => $numErrors, + ] + $identity); + } + + if (count($events) === $eventsBeforeDisk) { + panelcontrol_monitor_push_event($events, 'monitor.storage.health', 'disk', $target, 'normal', 'disk-present', [ + 'status' => $status, + ] + $identity); + } + } + + foreach ((array)$devs as $dev) { + $name = _var($dev,'name','no-name'); + $target = 'device:' . no_tilde((string)$name); + $temp = _var($dev,'temp','*'); + if (!is_numeric($temp)) { + continue; + } + + $tempInt = (int)$temp; + $hot = (int)_var($display,'hot',0); + $max = (int)_var($display,'max',0); + $tempState = exceed($tempInt,$max,$top) ? 'alert' : (exceed($tempInt,$hot,$top) ? 'warning' : ''); + if ($tempState !== '') { + panelcontrol_monitor_push_event($events, 'monitor.system.temperature', 'device', $target, $tempState, 'temperature-threshold', [ + 'temp' => $tempInt, + 'warning' => $hot, + 'critical' => $max, + ]); + } + } + + $targets = []; + $counts = ['off' => 0, 'normal' => 0, 'normal-blink' => 0, 'warning' => 0, 'alert' => 0]; + foreach ($events as $event) { + $target = (string)$event['target']; + $state = (string)$event['state']; + if ($target === '' || $state === '') { + continue; + } + if (!isset($targets[$target]) || panelcontrol_monitor_state_rank($state) > panelcontrol_monitor_state_rank($targets[$target])) { + $targets[$target] = $state; + } + } + + foreach ($targets as $state) { + if (isset($counts[$state])) { + $counts[$state]++; + } + } + + $payloadSavedState = is_array($saved) ? $saved : []; + if (isset($payloadSavedState['used'])) { + unset($payloadSavedState['used']); + } + + return [ + 'schema' => 'panelcontrol.monitor-payload.v1', + 'source' => 'unraid.monitor.copy', + 'generatedAt' => gmdate(DATE_RFC3339), + 'server' => (string)$server, + 'thresholds' => [ + 'docker_critical' => (int)$high1, + 'docker_warning' => (int)$high2, + ], + 'saved_state' => $payloadSavedState, + 'events' => $events, + 'targets' => $targets, + 'summary' => [ + 'event_count' => count($events), + 'targets_count' => count($targets), + 'state_counts' => $counts, + ], + ]; +} + +$panelcontrolShow = panelcontrol_monitor_has_show_flag(); + function check_temp(&$disk,$text,$info) { global $notify,$saved,$server,$display,$top; $name = _var($disk,'name'); @@ -387,5 +565,29 @@ if ($saved) { delete_file($ram,$rom); } } + +if ($panelcontrolShow) { + $payload = panelcontrol_monitor_build_payload($var, $disks, $devs, $saved, $display, $server, $high1, $high2, $top); + $payloadPath = '/usr/local/emhttp/state/panelcontrol-monitor-payload.json'; + $payloadJsonRaw = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if ($payloadJsonRaw === false) { + my_logger('Failed to encode panelcontrol monitor payload: '.json_last_error_msg(), 'webgui'); + } else { + $payloadJson = $payloadJsonRaw . "\n"; + $existingPayloadJson = @file_get_contents($payloadPath); + if ($existingPayloadJson !== $payloadJson) { + $tmpPath = $payloadPath . '.tmp'; + if (@file_put_contents($tmpPath, $payloadJson, LOCK_EX) !== false) { + if (!@rename($tmpPath, $payloadPath)) { + @file_put_contents($payloadPath, $payloadJson, LOCK_EX); + @unlink($tmpPath); + } + } else { + @file_put_contents($payloadPath, $payloadJson, LOCK_EX); + } + } + } +} + exit(0); ?> diff --git a/emhttp/plugins/unRAIDServer/EULA.page b/emhttp/plugins/unRAIDServer/EULA.page new file mode 100644 index 0000000000..71668ad473 --- /dev/null +++ b/emhttp/plugins/unRAIDServer/EULA.page @@ -0,0 +1,21 @@ +Menu="About" +Title="EULA" +Icon="icon-eula" +Tag="file-text-o" +--- + + +$file = "/boot/license.txt"; +if (file_exists($file)) echo Markdown(file_get_contents($file)); else echo Markdown("** No license file present! **"); +?> + diff --git a/emhttp/plugins/unRAIDServer/README.md b/emhttp/plugins/unRAIDServer/README.md new file mode 100644 index 0000000000..0692574c3b --- /dev/null +++ b/emhttp/plugins/unRAIDServer/README.md @@ -0,0 +1,3 @@ +**Unraid OS** + +Unraid OS by [Lime Technology, Inc.](https://lime-technology.com). diff --git a/emhttp/plugins/unRAIDServer/icons/eula.png b/emhttp/plugins/unRAIDServer/icons/eula.png new file mode 100644 index 0000000000..09c5757ed6 Binary files /dev/null and b/emhttp/plugins/unRAIDServer/icons/eula.png differ diff --git a/emhttp/plugins/unRAIDServer/icons/license.png b/emhttp/plugins/unRAIDServer/icons/license.png new file mode 100644 index 0000000000..d6e06fae5a Binary files /dev/null and b/emhttp/plugins/unRAIDServer/icons/license.png differ diff --git a/emhttp/plugins/unRAIDServer/images/unRAIDServer.png b/emhttp/plugins/unRAIDServer/images/unRAIDServer.png new file mode 100644 index 0000000000..d22f233133 Binary files /dev/null and b/emhttp/plugins/unRAIDServer/images/unRAIDServer.png differ diff --git a/emhttp/plugins/unRAIDServer/unRAIDServer.plg b/emhttp/plugins/unRAIDServer/unRAIDServer.plg new file mode 100644 index 0000000000..a9ddf690a3 --- /dev/null +++ b/emhttp/plugins/unRAIDServer/unRAIDServer.plg @@ -0,0 +1,420 @@ + + + + + + + + + + + + + + +]> + +