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