Pull to refresh

Веб-консоль на 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;
}
Tags:
Hubs:
You can’t comment this publication because its author is not yet a full member of the community. You will be able to contact the author only after he or she has been invited by someone in the community. Until then, author’s username will be hidden by an alias.