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