Code Coverage
 
Classes and Traits
Functions and Methods
Lines
Total
0.00% covered (danger)
0.00%
0 / 1
46.15% covered (danger)
46.15%
6 / 13
CRAP
64.34% covered (warning)
64.34%
92 / 143
Server
0.00% covered (danger)
0.00%
0 / 1
46.15% covered (danger)
46.15%
6 / 13
141.99
64.34% covered (warning)
64.34%
92 / 143
 main
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 execute
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
100.00%
7 / 7
 guardedExecute
0.00% covered (danger)
0.00%
0 / 1
10.15
75.76% covered (warning)
75.76%
25 / 33
 setupConfig
0.00% covered (danger)
0.00%
0 / 1
30.23
36.00% covered (danger)
36.00%
9 / 25
 getConfig
0.00% covered (danger)
0.00%
0 / 1
4.68
42.86% covered (danger)
42.86%
3 / 7
 forgetConfig
100.00% covered (success)
100.00%
1 / 1
4
100.00% covered (success)
100.00%
9 / 9
 setupLogger
0.00% covered (danger)
0.00%
0 / 1
13.83
48.15% covered (danger)
48.15%
13 / 27
 validateAction
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 handleException
0.00% covered (danger)
0.00%
0 / 1
5
95.24% covered (success)
95.24%
20 / 21
 handleError
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
1 / 1
 showHealth
0.00% covered (danger)
0.00%
0 / 1
2
0.00% covered (danger)
0.00%
0 / 5
 showSpec
0.00% covered (danger)
0.00%
0 / 1
2
0.00% covered (danger)
0.00%
0 / 3
 flushLogBuffer
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
100.00%
1 / 1
<?php
namespace Shellbox;
use GuzzleHttp\Psr7\Response;
use GuzzleHttp\Psr7\Uri;
use Monolog\Formatter\JsonFormatter;
use Monolog\Formatter\LineFormatter;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogHandler;
use Monolog\Logger;
use Monolog\Processor\PsrLogMessageProcessor;
use Shellbox\Action\CallAction;
use Shellbox\Action\ShellAction;
use Throwable;
/**
 * The Shellbox server main class
 *
 * To use this, create a PHP entry point file with:
 *
 *   require __DIR__ . '/vendor/autoload.php';
 *   Shellbox\Server::main();
 *
 */
class Server {
    /** @var array */
    private $config;
    /** @var bool[] */
    private $forgottenConfig = [];
    /** @var Logger */
    private $logger;
    /** @var ClientLogHandler|null */
    private $clientLogHandler;
    private const DEFAULT_CONFIG = [
        'allowedActions' => [ 'call', 'shell' ],
        'allowedRoutes' => null,
        'routeSpecs' => [],
        'useSystemd' => null,
        'useBashWrapper' => null,
        'useFirejail' => null,
        'firejailPath' => null,
        'firejailProfile' => null,
        'logFile' => false,
        'jsonLogFile' => false,
        'logToStderr' => false,
        'jsonLogToStderr' => false,
        'syslogIdent' => 'shellbox',
        'logToSyslog' => false,
        'logToClient' => true,
        'logFormat' => LineFormatter::SIMPLE_FORMAT
    ];
    /**
     * The main entry point. Call this from the webserver.
     *
     * @param string|null $configPath The location of the JSON config file
     */
    public static function main( $configPath = null ) {
        ( new self )->execute( $configPath );
    }
    /**
     * Non-static entry point
     *
     * @param string|null $configPath
     */
    protected function execute( $configPath ) {
        set_error_handler( [ $this, 'handleError' ] );
        try {
            $this->guardedExecute( $configPath );
        } catch ( Throwable $e ) {
            $this->handleException( $e );
        } finally {
            set_error_handler( null );
        }
    }
    /**
     * Entry point that may throw exceptions
     *
     * @param string|null $configPath
     */
    private function guardedExecute( $configPath ) {
        $this->setupConfig( $configPath );
        $this->setupLogger();
        $url = $_SERVER['REQUEST_URI'];
        $base = ( new Uri( $this->getConfig( 'url' ) ) )->getPath();
        if ( $base[-1] !== '/' ) {
            $base .= '/';
        }
        if ( substr_compare( $url, $base, 0, strlen( $base ) ) !== 0 ) {
            throw new ShellboxError( "Request URL does not match configured base path", 404 );
        }
        $baseLength = strlen( $base );
        $pathInfo = substr( $url, $baseLength );
        $components = explode( '/', $pathInfo );
        $action = array_shift( $components );
        if ( $action === '' ) {
            throw new ShellboxError( "No action was specified" );
        }
        if ( $action === 'healthz' ) {
            $this->showHealth();
            return;
        } elseif ( $action === 'spec' ) {
            $this->showSpec();
            return;
        }
        if ( $this->validateAction( $action ) ) {
            switch ( $action ) {
                case 'call':
                    $handler = new CallAction( $this );
                    break;
                case 'shell':
                    $handler = new ShellAction( $this );
                    break;
                default:
                    throw new ShellboxError( "Unknown action: $action" );
            }
        } else {
            throw new ShellboxError( "Invalid action: $action" );
        }
        $handler->setLogger( $this->logger );
        $handler->baseExecute( $components );
    }
    /**
     * Read the configuration file into $this->config
     *
     * @param string|null $configPath
     */
    private function setupConfig( $configPath ) {
        if ( $configPath === null ) {
            $configPath = $_ENV['SHELLBOX_CONFIG_PATH'] ?? '';
            if ( $configPath === '' ) {
                $configPath = __DIR__ . '/../shellbox-config.json';
            }
        }
        $json = file_get_contents( $configPath );
        if ( $json === false ) {
            throw new ShellboxError( 'This entry point is disabled: ' .
                "the configuration file $configPath is not present" );
        }
        $config = json_decode( $json, true );
        if ( $config === null ) {
            throw new ShellboxError( 'Error parsing JSON config file' );
        }
        $key = $_ENV['SHELLBOX_SECRET_KEY'] ?? $_SERVER['SHELLBOX_SECRET_KEY'] ?? '';
        if ( $key !== '' ) {
            if ( isset( $config['secretKey'] )
                && $key !== $config['secretKey']
            ) {
                throw new ShellboxError( 'The SHELLBOX_SECRET_KEY server ' .
                    'variable conflicts with the secretKey configuration' );
            }
            // Attempt to hide the key from code running later in the same request.
            // I think this could be made to be secure in plain CGI and
            // apache2handler, but it doesn't work in FastCGI or FPM modes.
            if ( function_exists( 'apache_setenv' ) ) {
                apache_setenv( 'SHELLBOX_SECRET_KEY', '' );
            }
            $_SERVER['SHELLBOX_SECRET_KEY'] = '';
            $_ENV['SHELLBOX_SECRET_KEY'] = '';
            putenv( 'SHELLBOX_SECRET_KEY=' );
            $config['secretKey'] = $key;
        }
        $this->config = $config + self::DEFAULT_CONFIG;
    }
    /**
     * Get a configuration variable
     *
     * @param string $name
     * @return mixed
     */
    public function getConfig( $name ) {
        if ( isset( $this->forgottenConfig[$name] ) ) {
            throw new ShellboxError( "Access to the configuration variable \"$name\" " .
                "is no longer possible" );
        }
        if ( !array_key_exists( $name, $this->config ) ) {
            throw new ShellboxError( "The configuration variable \"$name\" is required, " .
                "but it is not present in the config file." );
        }
        return $this->config[$name];
    }
    /**
     * Forget a configuration variable. This is used to try to hide the HMAC
     * key from code which is run by the call action.
     *
     * @param string $name
     */
    public function forgetConfig( $name ) {
        if ( isset( $this->config[$name] ) && is_string( $this->config[$name] ) ) {
            $conf =& $this->config[$name];
            $length = strlen( $conf );
            for ( $i = 0; $i < $length; $i++ ) {
                $conf[$i] = ' ';
            }
            unset( $conf );
        }
        unset( $this->config[$name] );
        $this->forgottenConfig[$name] = true;
    }
    /**
     * Set up logging based on current configuration.
     */
    private function setupLogger() {
        $this->logger = new Logger( 'shellbox' );
        $this->logger->pushProcessor( new PsrLogMessageProcessor );
        $formatter = new LineFormatter( $this->getConfig( 'logFormat' ) );
        $jsonFormatter = new JsonFormatter( JsonFormatter::BATCH_MODE_NEWLINES );
        if ( strlen( $this->getConfig( 'logFile' ) ) ) {
            $handler = new StreamHandler( $this->getConfig( 'logFile' ) );
            $handler->setFormatter( $formatter );
            $this->logger->pushHandler( $handler );
        }
        if ( strlen( $this->getConfig( 'jsonLogFile' ) ) ) {
            $handler = new StreamHandler( $this->getConfig( 'jsonLogFile' ) );
            $handler->setFormatter( $jsonFormatter );
            $this->logger->pushHandler( $handler );
        }
        if ( $this->getConfig( 'logToStderr' ) ) {
            $handler = new StreamHandler( 'php://stderr' );
            $handler->setFormatter( $formatter );
            $this->logger->pushHandler( $handler );
        }
        if ( $this->getConfig( 'jsonLogToStderr' ) ) {
            $handler = new StreamHandler( 'php://stderr' );
            $handler->setFormatter( $jsonFormatter );
            $this->logger->pushHandler( $handler );
        }
        if ( $this->getConfig( 'logToSyslog' ) ) {
            $this->logger->pushHandler(
                new SyslogHandler( $this->getConfig( 'syslogIdent' ) ) );
        }
        if ( $this->getConfig( 'logToClient' ) ) {
            $this->clientLogHandler = new ClientLogHandler;
            $this->logger->pushHandler( $this->clientLogHandler );
        }
    }
    /**
     * Check whether the action is in the list of allowed actions.
     *
     * @param string $action
     * @return bool
     */
    private function validateAction( $action ) {
        $allowed = $this->getConfig( 'allowedActions' );
        return in_array( $action, $allowed, true );
    }
    /**
     * Handle an exception.
     *
     * @param Throwable $exception
     */
    private function handleException( $exception ) {
        if ( $this->logger ) {
            $this->logger->error(
                "Exception of class " . get_class( $exception ) . ': ' .
                $exception->getMessage(),
                [
                    'trace' => $exception->getTraceAsString()
                ]
            );
        }
        if ( headers_sent() ) {
            return;
        }
        if ( $exception->getCode() >= 300 && $exception->getCode() < 600 ) {
            $code = $exception->getCode();
        } else {
            $code = 500;
        }
        $code = intval( $code );
        $response = new Response( $code );
        $reason = $response->getReasonPhrase();
        header( "HTTP/1.1 $code $reason" );
        header( 'Content-Type: application/json' );
        echo Shellbox::jsonEncode( [
            '__' => 'Shellbox server error',
            'class' => get_class( $exception ),
            'message' => $exception->getMessage(),
            'log' => $this->flushLogBuffer(),
        ] );
    }
    /**
     * Handle an error
     * @param int $level
     * @param string $message
     * @param string $file
     * @param int $line
     * @return never
     */
    public function handleError( $level, $message, $file, $line ) {
        throw new ShellboxError( "PHP error in $file line $line$message" );
    }
    /**
     * healthz action
     */
    private function showHealth() {
        header( 'Content-Type: application/json' );
        echo Shellbox::jsonEncode( [
            '__' => 'Shellbox running',
            'pid' => getmypid()
        ] );
    }
    /**
     * spec action
     */
    private function showSpec() {
        header( 'Content-Type: application/json' );
        echo file_get_contents( __DIR__ . '/spec.json' );
    }
    /**
     * Get the buffered log entries to return to the client, and clear the
     * buffer. If logToClient is false, this returns an empty array.
     *
     * @return array
     */
    public function flushLogBuffer() {
        return $this->clientLogHandler ? $this->clientLogHandler->flush() : [];
    }
}