<?php
/**
* icmd.php - a simple script to run shell commands and format their output
*
* This script accepts user input, prepends a defined command and executes
* it on your system. It can be used as an interface for non-interactive
* command-line programs where no web interface exists.
*
* ATTENTION: Unless used in a protected environment this script
* is to be considered inherently DANGEROUS. You have been warned!
*
* (License + History: see also end of file)
*
* @author     Andreas Schamanek <https://andreas.schamanek.net>
* @license    GPL <https://www.gnu.org/licenses/gpl.html>
* @copyright  (c) 2012-2021 Andreas Schamanek
*
*/
$version '<a href="https://fam.tuwien.ac.at/~schamane/sysadmin/icmd/">icmd.php</a> '
'by <a href="https://fam.tuwien.ac.at/~schamane/">Andreas Schamanek</a> '
'~ v0.13 ~ 2022-01-31 ~ '
'<a href="https://www.gnu.org/licenses/gpl.html">GPL licensed</a>';

// Default settings (overwritten by icmd.conf.php if available)
$icmd 'dig'// text shown left of input line
$icmdTitle 'icmd.php'// HTML title
$icmdUrl './'// URL for this script (used in ''<form action='' etc.)
$icmdCmd $icmd// actual command to execute (input will be appended)
$icmdInitialCmd '/usr/games/fortune'// executed if user entered no command
// or to just print some text, set $icmdInitialCmd='' and provide text here
$icmdIntro "<h1>Welcome</h1><p>... to $version</p>";

// Note that this script expects UTF-8. It is assumed that the server is
// set to deliver UTF-8, too. Make sure that also your shell understands it!
// Without UTF-8, the script might break and be an even greater security risk!
setlocale(LC_ALL'en_US.UTF-8');
// Warning: setlocale() is not thread-safe! See http://php.net/setlocale
mb_internal_encoding('UTF-8'); // requires php-mbstring

// Redirect after command (entered by user) was processed
// Example: $icmdCmd = 'todo add'; and: $icmdInitialCmd = 'todo list';
//  Now, everytime a user adds a todo, we want to redirect s/he to 'todo list'.
//  Hence we set e.g. $icmdUrl = './' and $icmdRedirectUrl = $icmdUrl;
$icmdRedirectUrl ''// disabled if empty
// Warning: Set with care to avoid redirection loops. The redirect is sent
//  if ($cmd != $icmdInitialCmd) because if ($cmd=='') $cmd = $icmdInitialCmd.
//  Avoid e.g.: $icmdRedirectUrl = $icmdUrl if $icmdInitialCmd == ''
//  and also: $icmdRedirectUrl = "$icmdUrl?cmd=something"
$icmdRedirectExitMax 0// no redirect if $exitcode is larger

// Generic regexes for URLs (used for instance in anchorurls())
$icmdRxUrlprot='(((http|ftp)s?|(sftp|smb)):\/\/)';
$icmdRxUrlauth='([a-z0-9][a-z0-9:_.-]@)?';
$icmdRxUrlhost='[a-z0-9]([a-z0-9_.-]*[a-z0-9])?\.[a-z0-9][a-z0-9_.-]*[a-z0-9]'// we require 1 dot and at least 2 chars at the end
$icmdRxUrlport='(:[0-9]+)?';
$icmdRxUrlpath='(\/[][a-z0-9@:;%_\+.~#?&\/=-]+)?';
$icmdRxUrl "/$icmdRxUrlprot$icmdRxUrlauth$icmdRxUrlhost$icmdRxUrlport$icmdRxUrlpath/i";
$icmdRxUrlWww "/(?<!:\/\/)www\.$icmdRxUrlhost$icmdRxUrlport$icmdRxUrlpath/i";

// Processing the input: A sketch of the input processing chain
//  (optional parts are put in [])
//
//  $_REQUEST['cmd'] -> convert to string -> mb_check_encoding
//  -> cut down to $icmdMaxlen -> [remove all but $icmdChars]
//  -> [processinput()] -> check if > $icmdMinlen
//  -> [processcmd()] OR escapeshellcmd -> exec("$cmd 2>&1", $results)
//  -> cut down to $icmdMaxbytes and $icmdMaxlines

// example for function to process user input/request
/*
function processinput($input) {
    // transliterate e.g. umlauts to correspondig ascii characters
    $input = iconv('UTF-8', 'ASCII//TRANSLIT', $input);
    return $input;
}
*/

// example for function to process and aggregate command to be executed
/*
function processcmd($cmd) { // do not forget escapeshellcmd() or similar!
    global $icmdCmd;
    // example: remove all @domains except email addresses and such
    $cmd = preg_replace('/(^| )@[a-z][a-z0-9.-]{3,}/i',' ',$cmd);
    // minimal example: prepend actual command and escapeshellcmd()
    $cmd = escapeshellcmd("$icmdCmd $cmd");
    // probably safer but less versatile is escapeshellarg()
    $cmd = "$icmdCmd ".escapeshellarg($cmd);
    // not working example with quotes (because escapeshellcmd breaks it ):
    //   $cmd = escapeshellcmd("$icmdCmd \"$cmd\"");
    // 2 examples that process the (now) fully aggregated command
    // if user entered 'help' make it '--help', in case of ''dig'' just '-h'
    $cmd = preg_replace('/^dig  *help *$/i','dig -h',$cmd);
    $cmd = preg_replace('/^([^ ]+)  *help *$/i','$1 --help',$cmd);
    return $cmd;  // do not forget escapeshellcmd() or similar!
}
*/

// Highlight matching keywords of $cmd (as entered by user)
// Note: Do not make it to arbitrary, it won't work.
//$icmdKeywordRegex = '/(\w{3,}|\d+)/'; // words of length 3+ and any digits
//$icmdKeywordRegex = '/(list|edit|delete)/' // specific words
$icmdKeywordRegex '/[\pL\pN._-]{3,}/u'// set to '' to disable

// Patterns to replace with $icmdReplacements in output (your main magic)
// Note: These substitutions are the first thing that is applied to the
// output of the shell command apart from a prior htmlspecialchars($output,
// ENT_QUOTES). So, for instance, to match a ">" use "&gt;". The reason is
// that this allows you to use HTML in $icmdReplacements, and the script
// won't encode the <, >, &, ', and " you introduced.
// If you generate anchors consider setting $icmdDisableAnchors = TRUE.
// Do not forget to use /.../u where needed for UTF-8.
$icmdPatterns = array(
    
/* digits */ '/(?<!&#)(?>[0-9][0-9,.\']*)(?!;)/',
    
/* alerts */ '/((?<!no |no)error|warning|unknown|not found|attention|alert)/i',
);
$icmdReplacements = array(
    
/* digits */ '<span class="digits">$0</span>',
    
/* alerts */ '<span class="alert">$0</span>',
);
// Protect parts from being replaced, e.g. URLs
// Note that the full regex must be enclosed in "()" and must not have other
// parenthesized groups as it is used w/ preg_split(). Make it empty to
// disable the preg_split and to use only preg_replace w/o preg_split.
$icmdPatternsExclude "/([hftpsmb]{3,5}:\/\/[^ ]+)/i";

$icmdDeleteLines ''// preg_match'ing output lines will be skipped
//$icmdDeleteLines = '/^(#|$)/'; // skip empty lines and # comments

$icmdDisableAnchors FALSE// make URLs in output clickable

$icmdAddLinebreaks TRUE// add <br> to each line of output

$icmdEcho TRUE// show last command in input line; empty if FALSE, or
// $icmdEcho = 'add '; // show specific string instead of last command

// HTML arguments added to input line, i.e. <input type=text $icmdAutoCtrl>
// consider adding 'autosuggest="off"'
$icmdAutoCtrl 'autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"';

$icmdContainerHeader ''// HTML code within div#container before #output
$icmdOutputStart ''// HTML code for start and end of output, e.g. <pre>
$icmdOutputEnd ''// + </pre>, or <ul> + </ul>; match w/ $icmdReplacements
//$icmdResultsHeader = ''; // set to '' to disable header above results,
// other values will be printed (consider setting it in processcmd())

$icmdMinlen 1// minimum length of command as provided by user
$icmdMaxlen 256// maximum length of command as provided by user
$icmdMaxlines 2000// maximum number of lines of output
$icmdMaxbytes 1048576// maximum number of bytes of output
// valid chars in user command (these are Unicode character classes,
// see https://www.php.net/manual/en/regexp.reference.unicode.php )
$icmdChars '\pL\pM\pN\pP\pS\pZ'// Letters, Marks, ...; disabled if ''

$icmdHeader ''// <html><head><title>... // default shown if empty
$icmdFooter "$version";

$icmdDebug FALSE// set to TRUE for inline debugging output
$icmdNoexec FALSE// set to TRUE for dry-running icmd.php

function debug($text) { global $icmdDebug; if ($icmdDebug) echo '<pre class="debug">'.hscq($text)."</pre>\n"; }
function 
warning($text) { echo "<pre class=\"debug\">[Warning]\n".hscq($text)."</pre>\n"; }
function 
hsc($str) { return htmlspecialchars($strENT_NOQUOTES ENT_DISALLOWED'UTF-8'); }
function 
hscc($str) { return htmlspecialchars($strENT_COMPAT ENT_DISALLOWED'UTF-8'); }
function 
hscq($str) { return htmlspecialchars($strENT_QUOTES ENT_DISALLOWED'UTF-8'); }

function 
icmdlog($msg) {
    
// defaults to syslog, define function icmdmylog() to use your own
    
if (function_exists('icmdmylog')) icmdmylog($msg);
    else {
        
// warning: beware of log injection attacks
        
openlog('icmd.php',LOG_ODELAY,LOG_USER); // warning: not thread-safe
        
syslog(LOG_INFOpreg_replace(
            array(
'/[^][\pL\pN_.,;:#\'+~*(){}|^!"$%&\/=?@\\\\ -]/u','/[<>]/'),
            array(
'.','^'), $msg// i like my logs somewhat readable
        
);
        
// syslog(LOG_INFO, hsc($msg)); // alternative approach
        
closelog(); // this is optional, but let's see if it resets the openlog
    
}
}
function 
anchorurls($text) { // disable by setting $icmdDisableAnchors = TRUE;
    
global $icmdRxUrl$icmdRxUrlWww;
    
$text preg_replace_exclHTML($icmdRxUrl'<a href="\0" target="_blank">\0</a>',$text);
    
// prepend http for urls starting without http:// but www.
    
$text preg_replace_exclHTML($icmdRxUrlWww'<a href="http://\0" target="_blank">\0</a>',$text);
    return 
$text;
}
function 
preg_replace_excl($patt,$repl,$excl,$str) { // skip excluded parts
    // (thanks to Gumbo @ https://stackoverflow.com/a/4604013/196133)
    // $excl must be a regex w/o parenthesized groups but fully enclosed in 1,
    // i.e. '/(regex)/mods', e.g. $excl = '/([hftpsmb]{3,5}:\/\/[^ ]+)/i';
    
$parts preg_split($excl$str, -1PREG_SPLIT_DELIM_CAPTURE);
    for (
$i=0$n=count($parts); $i<$n$i+=2) {
        
$parts[$i] = preg_replace($patt$repl$parts[$i]);
    }
    return 
implode(''$parts);
}
function 
preg_replace_exclHTML($patt,$repl,$str) {
    
// skip HTML when doing preg_replace; not good but will do in most cases
    
return preg_replace_excl($patt,$repl,
        
// '/(<[a-z][a-z0-9]*\b[^>]*>[^<]*<\/[a-z][a-z0-9]*>)/',
        // 2015-07-13: changed to code from Gumbo, see above
        
'/(<(?:[^"\'>]|"[^"<]*"|\'[^\'<]*\')*>)/',
        
$str);
}

// read optional config file
if (file_exists('icmd.conf.php') && is_readable('icmd.conf.php'))
include(
'icmd.conf.php');

// Please understand that any installation of this script is risky to some
// degree. The script refuses to run if neither the environment variable
// ICMDPHP nor HTTP_ICMDPHP is properly set. It is recommended to e.g.
// password protect the script and to set the variable within .htaccess
if (
        !( isset(
$_SERVER['REDIRECT_HTTP_ICMDPHP']) && $_SERVER['REDIRECT_HTTP_ICMDPHP']=='I understand the risk' )
        && !( isset(
$_SERVER['HTTP_ICMDPHP']) && $_SERVER['HTTP_ICMDPHP']=='I understand the risk' )
        && !( isset(
$_SERVER['REDIRECT_ICMDPHP']) && $_SERVER['REDIRECT_ICMDPHP']=='I understand the risk' )
        && !( isset(
$_SERVER['ICMDPHP']) && $_SERVER['ICMDPHP']=='I understand the risk' )
    )
    exit(
'icmd.php is not properly set up! Luke, use the <a href="https://fam.tuwien.ac.at/~schamane/sysadmin/icmd/">source</a>!');

ob_start();

if (
$icmdHeader) {
    echo 
"$icmdHeader\n";
} else {
    
// print default HTML header
echo <<<EOH
<!doctype html><html>
<head>
    <title>
$icmdTitle</title>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
    <link media="screen" href="icmd.css" rel="stylesheet" type="text/css">
    <meta name="viewport" content="width=device-width; initial-scale=1.447">
</head>
<body>\n
EOH;
}

// where there is a header, there should be a footer close by :)
function footer() {
    global 
$icmdFooter;
    echo 
"    </div>\n";
    if (
$icmdFooter) {
        echo 
"    <div id=\"footer\">\n    $icmdFooter\n    </div>\n";
    }
    echo 
"</div>\n</body>\n</html>\n";
}

// check if exec() is allowed
(in_array('exec',explode(', 'ini_get('disable_functions'))))
&& 
warning('exec() function seems to be disabled!');

// get requested command ($_REQUEST is already urldecoded)
$cmd = isset($_REQUEST['cmd']) ? (string) $_REQUEST['cmd'] : '' ;
// sanitize user input (cut down length, delete unallowed chars, ...)
if(!mb_check_encoding($cmd)) {
    
debug('Error: mb_check_encoding() returned FALSE');
    @
icmdlog("warning: possible invalid encoding attack from $_SERVER[REMOTE_ADDR]");
    
header($_SERVER['SERVER_PROTOCOL'].' 405 Method Not Allowed');
    exit(
'<h1>405 Method Not Allowed</h1>Invalid encoding');
}
$cmd substr($cmd,0,$icmdMaxlen);
// store this stage of processing (just for debugging, sorry)
$cmdunproc $cmd;
(
$icmdDebug) && ($cmd) && icmdlog("unprocessed cmd: $cmd");
// remove illegal characters (everything but $icmdChars)
if ($icmdChars) {
    
$cmd preg_replace("/[^$icmdChars]+/u"' '$cmd,-1,$illegalcount);
    (
$illegalcount != 0) && warning("Number of removed illegal characters: $illegalcount");
}
// process by means of processinput()
$cmd = (function_exists('processinput')) ? processinput($cmd) : $cmd;

// variables for HTML (just to keep clean the HTML below)
if ($icmdEcho)    $hscqcmd is_string($icmdEcho) ? hscq($icmdEcho) : hscq($cmd);
else 
$hscqcmd '';

echo <<<EOT
<div id="container">
    <div id="top">
        <div id="homelink"><a href="
$icmdUrl" accesskey="1">$icmd</a></div>
        <div id="formcontainer">
        <form id="icmd" name="icmd" action="
$icmdUrl" method="post"
                onsubmit="icmd.sub.disabled=true; icmd.sub.value='...'; return true;"
        >
            <input type="submit" id="sub" name="sub" value="Go" tabindex="2">
            <div id="command">
                <input type="text" id="cmd" name="cmd" accesskey="C" value="
$hscqcmd" tabindex="1" $icmdAutoCtrl>
            </div>
        </form>
        </div>
    </div>

    <script type="text/javascript">
        icmdform = document.getElementById("icmd");
        document.onload = icmdform.cmd.focus();
        window.onbeforeunload = function(){icmdform.sub.disabled=false;};
    </script>

    
$icmdContainerHeader
    <div id="output">\n
EOT;

debug("Unprocessed user request: $cmdunproc (len=".strlen($cmdunproc).")");
debug("Processed user request: $cmd (len=".strlen($cmd).")");

// bail out if command is too short
if (($cmd) && (strlen($cmd) < $icmdMinlen)) {
    
warning("Request too short. Minimum length is $icmdMinlen.");
    
footer(); exit;
}

// get highlighting keywords from $cmd (used for output highlighting)
if (($cmd) && ($icmdKeywordRegex) && !($icmdRedirectUrl)) {
    
preg_match_all($icmdKeywordRegex,$cmd,$icmdKeywords);
    
// convert keywords to regex: 1st escapes, then wrap in /\b.../iu
    
$icmdKeywords preg_replace(
        array(
'![][/\\\\.^$*+?(){}|-]!','/^.*$/'),
        array(
'\\\\$0','/\b$0/iu'),
        
$icmdKeywords[0]
    ); 
// note that this reduces $icmdKeywords[][] to $icmdKeywords[]
    
debug('$icmdKeywords (regex\'ed) = '.print_r($icmdKeywords,true));
}

// aggregate command: process, "$icmdCmd $cmd", or "$icmdInitialCmd"
if ($cmd) {
    
$cmd = (function_exists('processcmd')) ?
        
processcmd($cmd) : escapeshellcmd("$icmdCmd $cmd");
} else { 
// initial startup, no request by user
    
$cmd $icmdInitialCmd// if empty we print $icmdIntro, see below
}
    
// execute command + process output
if ($cmd) {
    
debug("Actual command: $cmd (len=".strlen($cmd).")");
    
icmdlog("$cmd");
    
$results=array();
    (
$icmdNoexec) || exec("$cmd 2>&1"$results$exitcode);
    
// limit $results to $icmdMaxbytes and count bytes and lines
    
$outputlen $outputlines 0;
    foreach (
$results as $line) {
        
$outputlen += strlen($line);
        if (
$outputlen $icmdMaxbytes) {
            
array_splice($results$outputlines);
            
$outputlen -= strlen($line);
            
debug('$results > $icmdMaxbytes; capped to'.$outputlines lines");
            break;
        } else 
$outputlines++;
    }
    
// reduce results to $icmdMaxlines lines
    
if (count($results) > $icmdMaxlines) {
        
debug('$results reduced to'.$icmdMaxlines lines");
        
array_splice($results$icmdMaxlines);
    }
    if (
$icmdDebug) {
        
$outputlines count($results);
        
debug("Return status: $exitcode; Size: $outputlen; Lines: $outputlines");
        if (
$icmdDebug) {
            echo 
"<pre class=\"debug\">``` \$results urlencoded:\n";
            foreach(
$results as $line) echo str_replace('%20',' ',rawurlencode($line))."\n";
            echo 
"```</pre>\n";
        }
    }

    
// Redirect to $icmdRedirectUrl
    
if (($cmd != $icmdInitialCmd) && ($icmdRedirectUrl)
        && (
$icmdRedirectExitMax >= $exitcode)) {
        
header("Location: $icmdRedirectUrl\n"); exit;
    }

    
// otherwise process output
    
echo isset($icmdResultsHeader) ?
        
"$icmdResultsHeader\n" "<h1>".hscq($cmd)."</h1>\n";
    echo 
"$icmdOutputStart\n";
    foreach(
$results as $line) {
        
// htmlspecialchars
        
$line hscc($line);
        
// lines to skip
        
if (($icmdDeleteLines) && preg_match($icmdDeleteLines,$line)) { continue; }
        
// main magic: replace $icmdPatterns w/ $icmdReplacements if not excluded
//        debug('before preg_replace: '.$line);
        
if ($icmdPatternsExclude) {
            
$line preg_replace_excl($icmdPatterns$icmdReplacements,    $icmdPatternsExclude$line);
        } else { 
// do just a "simple" more robust preg_replace
            
$line preg_replace($icmdPatterns$icmdReplacements,$line);
        }
//        debug('after preg_replace: '.$line);
        // line breaks
        
if ($icmdAddLinebreaks$line "$line<br>";
        
// make URLs clickable
        
if (!$icmdDisableAnchors$line anchorurls($line);
//        debug('anchorurls: '.$line);
        // highlight keywords from $cmd
        
if (isset($icmdKeywords) and $icmdKeywords) {
            
// maybe there's an easier way but preg_replace_callback() is all
            // I could come up with as long as I wanted to avoid HTML DOM
            // parsing etc. which anyway does not really apply to single lines.
            // later I found the approach used in preg_replace_excl() which
            // could be used but here we need it anyway as callback :)
            
$line preg_replace_callback(
                
// match text that is not part of HTML tags - regex found at
                // the very nice https://RegExr.com by https://gskinner.com/
                // and not an escaped HTML entity
                
'/(?<=^|>)[^><]+?(?=<|$)/',
                
// and replace any occurrences of the keywords
                // except within HTML entities
                
function($m) {
                    global 
$icmdKeywords;
                    return 
preg_replace_excl($icmdKeywords,
                        
'<span class="keyword">$0</span>',
                        
'/(&[a-z][a-z0-9]+;|&#[0-9]+;)/i',
                        
$m[0]);
                },
                
$line
            
);
            
// change back all previously protected HTML entities
            
$line preg_replace('/<(&([a-z][a-z0-9]+|#[0-9]+);)>/i','$1',$line);
        }
        
// dirty workaround for a rare case where anchors end with &quot;
        
$line preg_replace('/(<a href="[^"> ]+)&quot;(" target="[^"> ]+">[^"<> ]+)&quot;<\/a>/','$1$2</a>"',$line);
        
// encode single quotes and output line
        
echo str_replace("'",'&#039;',$line)."\n";
    }
    echo 
"$icmdOutputEnd\n";

} else { 
// i.e. ($cmd == '')
    // print optional variable $icmdIntro
    
if (isset($icmdIntro)) echo "$icmdIntro\n";
}

footer();

/**
* License
* ~~~~~~~
* This script is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This script is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this script; if not, write to the
* Free Software Foundation, Inc., 59 Temple Place, Suite 330,
* Boston, MA  02111-1307  USA
*
* History
*
* 0.01 * 2012-08-08  First 'please punish me' alpha draft version
* 0.02 * 2012-08-10  Added Javascript-less keyword highlighting
* 0.03 * 2012-08-16  1st version published, added processinput()
* 0.04 * 2012-08-23  Fine tuning, + $icmdRedirectUrl, + $icmdMinLen
* 0.05 * 2013-04-29  More tuning
* 0.06 * 2014-04-12  Fixed anchorurls() etc., - sl(), + $icmdPatternsExclude
* 0.07 * 2014-04-17  + mb_check_encoding etc., + $icmdContainerHeader, +icmdlog
* 0.08 * 2014-04-19  Replaced create_function(), icmd.php now requires PHP 5.3+
* 0.09 * 2014-05-23  + $icmdDeleteLines, + preg_replace_exclHTML(), ICMDPHP bug fix
* 0.10 * 2015-05-04  + $icmdRedirectExitMax, improved $exitcode handling, ...
* 0.11 * 2015-07-13  Changed regex for HTML in preg_replace_exclHTML()
* 0.12 * 2020-05-18  Fixed handling of $icmdChars
* 0.13 * 2022-01-31  + $icmdAutoCtrl to set autocorrect etc. for input line
*
*/