Как стать автором
Обновить

Веб-консоль на PHP

Для домашнего сервера понадобилось реализовать веб-консоль для Ubuntu. В поисках решения нашел на Хабре на эту тему статью — «Веб-консоль на 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;
}
Теги:
Хабы:
Данная статья не подлежит комментированию, поскольку её автор ещё не является полноправным участником сообщества. Вы сможете связаться с автором только после того, как он получит приглашение от кого-либо из участников сообщества. До этого момента его username будет скрыт псевдонимом.