Для домашнего сервера понадобилось реализовать веб-консоль для Ubuntu. В поисках решения нашел на Хабре на эту тему статью — «Веб-консоль на PHP».
Так как сервер работает в локальной сети (дома) и нет необходимости защищаться сертификатами, мне подошел данный скрипт. Он идеально работает, если его просто вставить в код файла и руками набирать какие-либо команды.
Весь мой проект написан на PHP. Есть свой планировщик (кроном вызывается файл cron.php). Но данный скрипт не позволял реализовать работу на чистом PHP. Я преобразовал его немного (потратил не много времени) и сделал новый класс Console, который позволяет отрисовать форму для работы пользователя и позволит выполнять команды на чистом PHP.
Моя реализация под катом.
Для отрисовки консоли необходимо подключить скрипт и вставить:
Для выполнения команд на стороне PHP необходимо:
На гитхабе пытаюсь связаться с создателем и предложить довести до ума мой класс. Данный класс является очень простым и не будет трудностей к его подключению.
Текст скрипта:
Так как сервер работает в локальной сети (дома) и нет необходимости защищаться сертификатами, мне подошел данный скрипт. Он идеально работает, если его просто вставить в код файла и руками набирать какие-либо команды.
Весь мой проект написан на PHP. Есть свой планировщик (кроном вызывается файл cron.php). Но данный скрипт не позволял реализовать работу на чистом PHP. Я преобразовал его немного (потратил не много времени) и сделал новый класс Console, который позволяет отрисовать форму для работы пользователя и позволит выполнять команды на чистом PHP.
Моя реализация под катом.
Для отрисовки консоли необходимо подключить скрипт и вставить:
$console = new Console();
echo $console->draw();
Для выполнения команд на стороне PHP необходимо:
$console = new Console();
printr($console->doCommand('cd /'));
printr($console->doCommand('ls'));
printr($console->doCommand('sudo minidlna -R'));
printr($console->doCommand('sudo /etc/init.d/minidlna restart'));
На гитхабе пытаюсь связаться с создателем и предложить довести до ума мой класс. Данный класс является очень простым и не будет трудностей к его подключению.
Текст скрипта:
<?
class Console{
var $users = array();
var $realm = 'Console';
var $theme = 'default';
var $commands = array(
'*' => '$1',
);
// Start with this dir.
var $currentDir = __DIR__;
var $currentDirName;
var $allowChangeDir = true;
// Allowed and denied commands.
var $allow = array();
var $deny = array();
var $autocomplete = array(
'^\w*$' => array('cd', 'ls', 'mkdir', 'chmod', 'git', 'hg', 'diff', 'rm', 'mv', 'cp', 'more', 'grep', 'ff', 'whoami', 'kill'),
'^git \w*$' => array('status', 'push', 'pull', 'add', 'bisect', 'branch', 'checkout', 'clone', 'commit', 'diff', 'fetch', 'grep', 'init', 'log', 'merge', 'mv', 'rebase', 'reset', 'rm', 'show', 'tag', 'remote'),
'^git \w* .*' => array('HEAD', 'origin', 'master', 'production', 'develop', 'rename', '--cached', '--global', '--local', '--merged', '--no-merged', '--amend', '--tags', '--no-hardlinks', '--shared', '--reference', '--quiet', '--no-checkout', '--bare', '--mirror', '--origin', '--upload-pack', '--template=', '--depth', '--help'),
);
function __construct(){
$this->authentication();
// Use next two for long time executing commands.
ignore_user_abort(true);
set_time_limit(0);
// If exist config include it.
if (is_readable($file = __DIR__ . '/console.config.php')) {
include $file;
}
// If we have a user command execute it.
// Otherwise send user interface.
if (isset($_GET['command'])) {
$this->userCommand = urldecode($_GET['command']);
$this->userCommand = escapeshellcmd($this->userCommand);
} else {
$this->userCommand = false;
// Send frontend to user.
header('Content-Type: text/html; charset=utf-8');
// Show current dir name.
$this->currentDirName = explode('/', $this->currentDir);
$this->currentDirName = end($this->currentDirName);
// Show current user.
$whoami = isset($this->commands['*']) ? str_replace('$1', 'whoami', $this->commands['*']) : 'whoami';
list($this->currentUser) = Console::executeCommand($whoami);
$this->currentUser = trim($this->currentUser);
// printr($currentUser);
}
// If can - get current dir.
if ($this->allowChangeDir && isset($_GET['cd'])) {
$newDir = urldecode($_GET['cd']);
if (is_dir($newDir)) {
$this->currentDir = $newDir;
}
}
// echo $this->doCommand();
}
function authentication(){
// If auth is enabled:
if (!empty($this->users)) {
if (empty($_SERVER['PHP_AUTH_DIGEST'])) {
header('HTTP/1.1 401 Unauthorized');
header('WWW-Authenticate: Digest realm="' . $this->realm . '",qop="auth",nonce="' . uniqid() . '",opaque="' . md5($this->realm) . '"');
die("Bye-bye!\n");
}
// Analyze the PHP_AUTH_DIGEST variable
if (!($data = Console::httpDigestParse($_SERVER['PHP_AUTH_DIGEST'])) || !isset($this->users[$data['username']])) {
die("Wrong Credentials!\n");
}
// Generate the valid response
$A1 = md5($data['username'] . ':' . $this->realm . ':' . $this->users[$data['username']]);
$A2 = md5($_SERVER['REQUEST_METHOD'] . ':' . $data['uri']);
$valid_response = md5($A1 . ':' . $data['nonce'] . ':' . $data['nc'] . ':' . $data['cnonce'] . ':' . $data['qop'] . ':' . $A2);
if ($data['response'] != $valid_response) {
die("Wrong Credentials!\n");
}
// ok, valid username & password
$httpUsername = $data['username'];
}
}
function draw(){
ob_start();
?>
<style type="text/css">
form {
display: table;
width: 100%;
white-space: nowrap;
}
form div {
display: table-cell;
width: auto;
}
form #command {
width: 100%;
}
input {
border: none;
outline: none;
background: transparent;
width: 100%;
}
input:focus {
outline: none;
}
pre, form, input {
color: inherit;
font-family: inherit;
font-size: inherit;
}
pre {
white-space: pre;
}
code {
color: blue;
font-family: inherit;
font-size: inherit;
}
strong {
font-weight: bolder;
font-family: Tahoma, Geneva, sans-serif
}
.error {
color: red;
}
.autocomplete .guess {
color: #a9a9a9;
}
.diff-header {
color: #333;
font-weight: bold;
}
.diff-sub-header {
color: #33a;
}
.diff-added {
color: #3a3;
}
.diff-deleted {
color: #a33;
}
</style>
<?php if ($this->theme == "ubuntu") { ?>
<style type="text/css">
body {
color: #FFFFFF;
background-color: #281022;
}
#commandPre{
color: #FFFFFF;
background-color: #281022;
font-family: monospace;
}
code {
color: #898989;
}
.diff-header {
color: #FFF;
}
</style>
<?php } elseif ($this->theme == "grey") { ?>
<style type="text/css">
body {
color: #B8B8B8;
background-color: #424242;
font-family: Monaco, Courier, monospace;
}
code {
color: #FFFFFF;
}
form, input {
color: #FFFFFF;
}
.diff-header {
color: #B8B8B8;
}
.diff-sub-header {
color: #cbcbcb;
}
</style>
<?php } elseif ($this->theme == "far") { ?>
<style type="text/css">
body {
color: #CCCCCC;
background-color: #001F7C;
font-family: Terminal, monospace;
}
code {
color: #6CF7FC;
}
.diff-header {
color: aqua;
}
.diff-sub-header {
color: #1f7184;
}
</style>
<?php } elseif ($this->theme == "white") { ?>
<style type="text/css">
body {
color: #FFFFFF;
background-color: #000000;
font-family: monospace;
}
code {
color: #898989;
}
.diff-header {
color: #FFF;
}
</style>
<?php } elseif ($this->theme == "green") { ?>
<style type="text/css">
body {
background-color: #000000;
color: #00C000;
font-family: monospace;
}
code {
color: #00C000;
}
.diff-added {
color: #23be8c;
}
</style>
<?
}
?>
<script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js"></script>
<script type="text/javascript">
/**
* History of commands.
*/
(function ($) {
var maxHistory = 100;
var position = -1;
var currentCommand = '';
var addCommand = function (command) {
var ls = localStorage['commands'];
var commands = ls ? JSON.parse(ls) : [];
if (commands.length > maxHistory) {
commands.shift();
}
commands.push(command);
localStorage['commands'] = JSON.stringify(commands);
};
var getCommand = function (at) {
var ls = localStorage['commands'];
var commands = ls ? JSON.parse(ls) : [];
if (at < 0) {
position = at = -1;
return currentCommand;
}
if (at >= commands.length) {
position = at = commands.length - 1;
}
return commands[commands.length - at - 1];
};
$.fn.history = function () {
var input = $(this);
input.keydown(function (e) {
var code = (e.keyCode ? e.keyCode : e.which);
if (code == 38) { // Up
if (position == -1) {
currentCommand = input.val();
}
input.val(getCommand(++position));
return false;
} else if (code == 40) { // Down
input.val(getCommand(--position));
return false;
} else {
position = -1;
}
});
return input;
};
$.fn.addHistory = function (command) {
addCommand(command);
};
})(jQuery);
/**
* Autocomplete input.
*/
(function ($) {
$.fn.autocomplete = function (suggest) {
// Wrap and extra html to input.
var input = $(this);
input.wrap('<span class="autocomplete" style="position: relative;"></span>');
var html =
'<span class="overflow" style="position: absolute; z-index: -10;">' +
'<span class="repeat" style="opacity: 0;"></span>' +
'<span class="guess"></span></span>';
$('.autocomplete').prepend(html);
// Search of input changes.
var repeat = $('.repeat');
var guess = $('.guess');
var search = function (command) {
var array = [];
for (var key in suggest) {
if (!suggest.hasOwnProperty(key))
continue;
var pattern = new RegExp(key);
if (command.match(pattern)) {
array = suggest[key];
}
}
var text = command.split(' ').pop();
var found = '';
if (text != '') {
for (var i = 0; i < array.length; i++) {
var value = array[i];
if (value.length > text.length &&
value.substring(0, text.length) == text) {
found = value.substring(text.length, value.length);
break;
}
}
}
guess.text(found);
};
var update = function () {
var command = input.val();
repeat.text(command);
search(command);
};
input.change(update);
input.keyup(update);
input.keypress(update);
input.keydown(update);
input.keydown(function (e) {
var code = (e.keyCode ? e.keyCode : e.which);
if (code == 9) {
var val = input.val();
input.val(val + guess.text());
return false;
}
});
return input;
};
})(jQuery);
/**
* Windows variables.
*/
window.currentDir = '<? echo $this->currentDirName; ?>';
window.currentDirName = window.currentDir.split('/').pop();
window.currentUser = '<? echo $this->currentUser; ?>';
window.titlePattern = "* — console";
window.document.title = window.titlePattern.replace('*', window.currentDirName);
/**
* Init console.
*/
$(function () {
var screen = $('#commandPre');
var input = $('#commandInput');
var form = $('#commandForm');
var scroll = function () {
window.scrollTo(0, document.body.scrollHeight);
};
input.history();
input.autocomplete(<? echo json_encode($this->autocomplete); ?>);
form.submit(function () {
var command = $.trim(input.val());
if (command == '') {
return false;
}
$("<code>" + window.currentDirName + " " + window.currentUser + "$ " + command + "</code>
").appendTo(screen);
scroll();
input.val('');
form.hide();
input.addHistory(command);
$.get('', {'act' : 'doCommand','command': command, 'cd': window.currentDir}, function (output) {
var pattern = /^set current directory (.+?)$/i;
if (matches = output.match(pattern)) {
window.currentDir = matches[1];
window.currentDirName = window.currentDir.split('/').pop();
$('#currentDirName').text(window.currentDirName);
window.document.title = window.titlePattern.replace('*', window.currentDirName);
} else {
screen.append(output);
}
})
.fail(function () {
screen.append("<span class='error'>Command is sent, but due to an HTTP error result is not known.</span>\n");
})
.always(function () {
form.show();
scroll();
});
return false;
});
$(document).keydown(function () {
input.focus();
});
});
</script>
<pre id='commandPre'></pre>
<form id='commandForm'>
<div id="currentDirName"><?php echo $this->currentDirName; ?></div>
<div> <?php echo $this->currentUser; ?>$ </div>
<div id="command"><input type="text" id='commandInput' value=""></div>
</form>
<?
$ret = ob_get_contents();
ob_end_clean();
return $ret;
}
function httpDigestParse($txt)
{
// protect against missing data
$needed_parts = array('nonce' => 1, 'nc' => 1, 'cnonce' => 1, 'qop' => 1, 'username' => 1, 'uri' => 1, 'response' => 1);
$data = array();
$keys = implode('|', array_keys($needed_parts));
preg_match_all('@(' . $keys . ')=(?:([\'"])([^\2]+?)\2|([^\s,]+))@', $txt, $matches, PREG_SET_ORDER);
foreach ($matches as $m) {
$data[$m[1]] = $m[3] ? $m[3] : $m[4];
unset($needed_parts[$m[1]]);
}
return $needed_parts ? false : $data;
}
function formatDiff($output)
{
$lines = explode("\n", $output);
foreach ($lines as $key => $line) {
if (strpos($line, "-") === 0) {
$lines[$key] = '<span class="diff-deleted">' . $line . '</span>';
}
if (strpos($line, "+") === 0) {
$lines[$key] = '<span class="diff-added">' . $line . '</span>';
}
if (preg_match("%^@@.*?@@%is", $line)) {
$lines[$key] = '<span class="diff-sub-header">' . $line . '</span>';
}
if (preg_match("%^index\s[^.]*?\.\.\S*?\s\S*?%is", $line) || preg_match("%^diff.*?a.*?b%is", $line)) {
$lines[$key] = '<span class="diff-header">' . $line . '</span>';
}
}
return implode("\n", $lines);
}
function formatHelp($output)
{
// Underline words with _0x08* symbols.
$output = preg_replace('/_[\b](.)/is', "<u>$1</u>", $output);
// Highlight backslash words with *0x08* symbols.
$output = preg_replace('/.[\b](.)/is', "<strong>$1</strong>", $output);
return $output;
}
function formatOutput($command, $output)
{
if (preg_match("%^(git )?diff%is", $command) || preg_match("%^status.*?-.*?v%is", $command)) {
$output = Console::formatDiff($output);
}
$output = Console::formatHelp($output);
return $output;
}
function searchCommand($userCommand, array $commands, &$found = false, $inValues = true)
{
foreach ($commands as $key => $value) {
list($pattern, $format) = $inValues ? array($value, '$1') : array($key, $value);
$pattern = '/^' . str_replace('\*', '(.*?)', preg_quote($pattern)) . '$/i';
if (preg_match($pattern, $userCommand)) {
if (false !== $found) {
$found = preg_replace($pattern, $format, $userCommand);
}
return true;
}
}
return false;
}
function executeCommand($command)
{
$descriptors = array(
0 => array("pipe", "r"), // stdin - read channel
1 => array("pipe", "w"), // stdout - write channel
2 => array("pipe", "w"), // stdout - error channel
3 => array("pipe", "r"), // stdin - This is the pipe we can feed the password into
);
$process = proc_open($command, $descriptors, $pipes);
if (!is_resource($process)) {
die("Can't open resource with proc_open.");
}
// Nothing to push to input.
fclose($pipes[0]);
$output = stream_get_contents($pipes[1]);
fclose($pipes[1]);
$error = stream_get_contents($pipes[2]);
fclose($pipes[2]);
// TODO: Write passphrase in pipes[3].
fclose($pipes[3]);
// Close all pipes before proc_close!
$code = proc_close($process);
return array($output, $error, $code);
}
function doCommand($do_command = false){
ob_start();
if ($do_command){
$this->userCommand = $do_command;
}
if (false !== $this->userCommand) {
// Check command by allow list.
if (!empty($this->allow)) {
if (!Console::searchCommand($this->userCommand, $this->allow)) {
$these = implode('
', $this->allow);
die("<span class='error'>Sorry, but this command not allowed. Try these:
{$these}</span>\n");
}
}
// Check command by deny list.
if (!empty($this->deny)) {
if (Console::searchCommand($this->userCommand, $this->deny)) {
die("<span class='error'>Sorry, but this command is denied.</span>\n");
}
}
// Change current dir.
if ($this->allowChangeDir && 1 === preg_match('/^cd\s+(?<path>.+?)$/i', $this->userCommand, $matches)) {
$newDir = $matches['path'];
$newDir = '/' === $newDir[0] ? $newDir : $this->currentDir . '/' . $newDir;
if (is_dir($newDir)) {
$newDir = realpath($newDir);
// Interface will recognize this and save as current dir.
echo "set current directory $newDir";
$this->currentDir = $newDir;//что бы изменить дирректорию если идет обращение к классу ПХП
} else {
die("<span class='error'>cd: $newDir: No such directory.</span>\n");
}
}
// Check if command is not in commands list.
if (!Console::searchCommand($this->userCommand, $this->commands, $command, false)) {
$these = implode('
', array_keys($this->commands));
die("<span class='error'>Sorry, but this command not allowed. Try these:
{$these}</span>");
}
// Create final command and execute it.
$command = "cd $this->currentDir && $command";
list($output, $error, $code) = Console::executeCommand($command);
echo Console::formatOutput($this->userCommand, htmlspecialchars($output));
echo htmlspecialchars($error);
}
$ret = ob_get_contents();
ob_end_clean();
return $ret;
}
}
if ($_GET['act'] == "doCommand"){
$console = new Console();
echo $console->doCommand();
exit;
}