Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 137
0.00% covered (danger)
0.00%
0 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
Server
0.00% covered (danger)
0.00%
0 / 137
0.00% covered (danger)
0.00%
0 / 13
2256
0.00% covered (danger)
0.00%
0 / 1
 main
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 guardedExecute
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
110
 setupConfig
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
90
 getConfig
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 forgetConfig
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 setupLogger
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
56
 validateAction
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 handleException
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
30
 handleError
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 showHealth
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 showSpec
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 flushLogBuffer
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace Shellbox;
4
5use GuzzleHttp\Psr7\Response;
6use GuzzleHttp\Psr7\Uri;
7use Monolog\Formatter\JsonFormatter;
8use Monolog\Formatter\LineFormatter;
9use Monolog\Handler\StreamHandler;
10use Monolog\Handler\SyslogHandler;
11use Monolog\Logger;
12use Monolog\Processor\PsrLogMessageProcessor;
13use Shellbox\Action\CallAction;
14use Shellbox\Action\ShellAction;
15use Throwable;
16
17/**
18 * The Shellbox server main class
19 *
20 * To use this, create a PHP entry point file with:
21 *
22 *   require __DIR__ . '/vendor/autoload.php';
23 *   Shellbox\Server::main();
24 *
25 */
26class Server {
27    /** @var array */
28    private $config;
29    /** @var bool[] */
30    private $forgottenConfig = [];
31    /** @var Logger */
32    private $logger;
33    /** @var ClientLogHandler|null */
34    private $clientLogHandler;
35
36    private const DEFAULT_CONFIG = [
37        'allowedActions' => [ 'call', 'shell' ],
38        'allowedRoutes' => null,
39        'routeSpecs' => [],
40        'useSystemd' => null,
41        'useBashWrapper' => null,
42        'useFirejail' => null,
43        'firejailPath' => null,
44        'firejailProfile' => null,
45        'logFile' => false,
46        'jsonLogFile' => false,
47        'logToStderr' => false,
48        'jsonLogToStderr' => false,
49        'syslogIdent' => 'shellbox',
50        'logToSyslog' => false,
51        'logToClient' => true,
52        'logFormat' => LineFormatter::SIMPLE_FORMAT,
53        'allowUrlFiles' => false,
54        'urlFileConcurrency' => 5,
55        'urlFileConnectTimeout' => 3,
56        'urlFileRequestTimeout' => 600,
57        'urlFileUploadAttempts' => 3,
58        'urlFileRetryDelay' => 1,
59    ];
60
61    /**
62     * The main entry point. Call this from the webserver.
63     *
64     * @param string|null $configPath The location of the JSON config file
65     */
66    public static function main( $configPath = null ) {
67        ( new self )->execute( $configPath );
68    }
69
70    /**
71     * Non-static entry point
72     *
73     * @param string|null $configPath
74     */
75    protected function execute( $configPath ) {
76        set_error_handler( [ $this, 'handleError' ] );
77        try {
78            $this->guardedExecute( $configPath );
79        } catch ( Throwable $e ) {
80            $this->handleException( $e );
81        } finally {
82            set_error_handler( null );
83        }
84    }
85
86    /**
87     * Entry point that may throw exceptions
88     *
89     * @param string|null $configPath
90     */
91    private function guardedExecute( $configPath ) {
92        $this->setupConfig( $configPath );
93        $this->setupLogger();
94
95        $url = $_SERVER['REQUEST_URI'];
96        $base = ( new Uri( $this->getConfig( 'url' ) ) )->getPath();
97        if ( $base[-1] !== '/' ) {
98            $base .= '/';
99        }
100        if ( substr_compare( $url, $base, 0, strlen( $base ) ) !== 0 ) {
101            throw new ShellboxError( "Request URL does not match configured base path", 404 );
102        }
103        $baseLength = strlen( $base );
104        $pathInfo = substr( $url, $baseLength );
105        $components = explode( '/', $pathInfo );
106        $action = array_shift( $components );
107
108        if ( $action === '' ) {
109            throw new ShellboxError( "No action was specified" );
110        }
111        if ( $action === 'healthz' ) {
112            $this->showHealth();
113            return;
114        } elseif ( $action === 'spec' ) {
115            $this->showSpec();
116            return;
117        }
118
119        if ( $this->validateAction( $action ) ) {
120            switch ( $action ) {
121                case 'call':
122                    $handler = new CallAction( $this );
123                    break;
124
125                case 'shell':
126                    $handler = new ShellAction( $this );
127                    break;
128
129                default:
130                    throw new ShellboxError( "Unknown action: $action" );
131            }
132        } else {
133            throw new ShellboxError( "Invalid action: $action" );
134        }
135
136        $handler->setLogger( $this->logger );
137        $handler->baseExecute( $components );
138    }
139
140    /**
141     * Read the configuration file into $this->config
142     *
143     * @param string|null $configPath
144     */
145    private function setupConfig( $configPath ) {
146        if ( $configPath === null ) {
147            $configPath = $_ENV['SHELLBOX_CONFIG_PATH'] ?? '';
148            if ( $configPath === '' ) {
149                $configPath = __DIR__ . '/../shellbox-config.json';
150            }
151        }
152        $json = file_get_contents( $configPath );
153        if ( $json === false ) {
154            throw new ShellboxError( 'This entry point is disabled: ' .
155                "the configuration file $configPath is not present" );
156        }
157        $config = json_decode( $json, true );
158        if ( $config === null ) {
159            throw new ShellboxError( 'Error parsing JSON config file' );
160        }
161
162        $key = $_ENV['SHELLBOX_SECRET_KEY'] ?? $_SERVER['SHELLBOX_SECRET_KEY'] ?? '';
163        if ( $key !== '' ) {
164            if ( isset( $config['secretKey'] )
165                && $key !== $config['secretKey']
166            ) {
167                throw new ShellboxError( 'The SHELLBOX_SECRET_KEY server ' .
168                    'variable conflicts with the secretKey configuration' );
169            }
170            // Attempt to hide the key from code running later in the same request.
171            // I think this could be made to be secure in plain CGI and
172            // apache2handler, but it doesn't work in FastCGI or FPM modes.
173            if ( function_exists( 'apache_setenv' ) ) {
174                apache_setenv( 'SHELLBOX_SECRET_KEY', '' );
175            }
176            $_SERVER['SHELLBOX_SECRET_KEY'] = '';
177            $_ENV['SHELLBOX_SECRET_KEY'] = '';
178            putenv( 'SHELLBOX_SECRET_KEY=' );
179            $config['secretKey'] = $key;
180        }
181
182        $this->config = $config + self::DEFAULT_CONFIG;
183    }
184
185    /**
186     * Get a configuration variable
187     *
188     * @param string $name
189     * @return mixed
190     */
191    public function getConfig( $name ) {
192        if ( isset( $this->forgottenConfig[$name] ) ) {
193            throw new ShellboxError( "Access to the configuration variable \"$name\" " .
194                "is no longer possible" );
195        }
196        if ( !array_key_exists( $name, $this->config ) ) {
197            throw new ShellboxError( "The configuration variable \"$name\" is required, " .
198                "but it is not present in the config file." );
199        }
200        return $this->config[$name];
201    }
202
203    /**
204     * Forget a configuration variable. This is used to try to hide the HMAC
205     * key from code which is run by the call action.
206     *
207     * @param string $name
208     */
209    public function forgetConfig( $name ) {
210        if ( isset( $this->config[$name] ) && is_string( $this->config[$name] ) ) {
211            $conf =& $this->config[$name];
212            $length = strlen( $conf );
213            for ( $i = 0; $i < $length; $i++ ) {
214                $conf[$i] = ' ';
215            }
216            unset( $conf );
217        }
218        unset( $this->config[$name] );
219        $this->forgottenConfig[$name] = true;
220    }
221
222    /**
223     * Set up logging based on current configuration.
224     */
225    private function setupLogger() {
226        $this->logger = new Logger( 'shellbox' );
227        $this->logger->pushProcessor( new PsrLogMessageProcessor );
228        $formatter = new LineFormatter( $this->getConfig( 'logFormat' ) );
229        $jsonFormatter = new JsonFormatter( JsonFormatter::BATCH_MODE_NEWLINES );
230
231        if ( strlen( $this->getConfig( 'logFile' ) ) ) {
232            $handler = new StreamHandler( $this->getConfig( 'logFile' ) );
233            $handler->setFormatter( $formatter );
234            $this->logger->pushHandler( $handler );
235        }
236        if ( strlen( $this->getConfig( 'jsonLogFile' ) ) ) {
237            $handler = new StreamHandler( $this->getConfig( 'jsonLogFile' ) );
238            $handler->setFormatter( $jsonFormatter );
239            $this->logger->pushHandler( $handler );
240        }
241        if ( $this->getConfig( 'logToStderr' ) ) {
242            $handler = new StreamHandler( 'php://stderr' );
243            $handler->setFormatter( $formatter );
244            $this->logger->pushHandler( $handler );
245        }
246        if ( $this->getConfig( 'jsonLogToStderr' ) ) {
247            $handler = new StreamHandler( 'php://stderr' );
248            $handler->setFormatter( $jsonFormatter );
249            $this->logger->pushHandler( $handler );
250        }
251        if ( $this->getConfig( 'logToSyslog' ) ) {
252            $this->logger->pushHandler(
253                new SyslogHandler( $this->getConfig( 'syslogIdent' ) ) );
254        }
255        if ( $this->getConfig( 'logToClient' ) ) {
256            $this->clientLogHandler = new ClientLogHandler;
257            $this->logger->pushHandler( $this->clientLogHandler );
258        }
259    }
260
261    /**
262     * Check whether the action is in the list of allowed actions.
263     *
264     * @param string $action
265     * @return bool
266     */
267    private function validateAction( $action ) {
268        $allowed = $this->getConfig( 'allowedActions' );
269        return in_array( $action, $allowed, true );
270    }
271
272    /**
273     * Handle an exception.
274     *
275     * @param Throwable $exception
276     */
277    private function handleException( $exception ) {
278        if ( $this->logger ) {
279            $this->logger->error(
280                "Exception of class " . get_class( $exception ) . ': ' .
281                $exception->getMessage(),
282                [
283                    'trace' => $exception->getTraceAsString()
284                ]
285            );
286        }
287
288        if ( headers_sent() ) {
289            return;
290        }
291
292        if ( $exception->getCode() >= 300 && $exception->getCode() < 600 ) {
293            $code = $exception->getCode();
294        } else {
295            $code = 500;
296        }
297        $code = intval( $code );
298        $response = new Response( $code );
299        $reason = $response->getReasonPhrase();
300        header( "HTTP/1.1 $code $reason" );
301        header( 'Content-Type: application/json' );
302
303        echo Shellbox::jsonEncode( [
304            '__' => 'Shellbox server error',
305            'class' => get_class( $exception ),
306            'message' => $exception->getMessage(),
307            'log' => $this->flushLogBuffer(),
308        ] );
309    }
310
311    /**
312     * Handle an error
313     * @param int $level
314     * @param string $message
315     * @param string $file
316     * @param int $line
317     * @return never
318     */
319    public function handleError( $level, $message, $file, $line ) {
320        throw new ShellboxError( "PHP error in $file line $line$message" );
321    }
322
323    /**
324     * healthz action
325     */
326    private function showHealth() {
327        header( 'Content-Type: application/json' );
328        echo Shellbox::jsonEncode( [
329            '__' => 'Shellbox running',
330            'pid' => getmypid()
331        ] );
332    }
333
334    /**
335     * spec action
336     */
337    private function showSpec() {
338        header( 'Content-Type: application/json' );
339        echo file_get_contents( __DIR__ . '/spec.json' );
340    }
341
342    /**
343     * Get the buffered log entries to return to the client, and clear the
344     * buffer. If logToClient is false, this returns an empty array.
345     *
346     * @return array
347     */
348    public function flushLogBuffer() {
349        return $this->clientLogHandler ? $this->clientLogHandler->flush() : [];
350    }
351}