1: <?php declare(strict_types=1);
2:
3: namespace Salient\Http\Server;
4:
5: use Salient\Contract\Core\Immutable;
6: use Salient\Contract\Http\Exception\InvalidHeaderException as InvalidHeaderExceptionInterface;
7: use Salient\Contract\Http\Message\ServerRequestInterface;
8: use Salient\Contract\Http\HasHttpHeader;
9: use Salient\Contract\Http\HasRequestMethod;
10: use Salient\Core\Concern\ImmutableTrait;
11: use Salient\Http\Exception\InvalidHeaderException;
12: use Salient\Http\Exception\ServerException;
13: use Salient\Http\Message\Response;
14: use Salient\Http\Message\ServerRequest;
15: use Salient\Http\Headers;
16: use Salient\Http\HttpUtil;
17: use Salient\Http\Uri;
18: use Salient\Utility\File;
19: use Salient\Utility\Regex;
20: use Salient\Utility\Str;
21: use InvalidArgumentException;
22: use LogicException;
23: use Throwable;
24:
25: /**
26: * A simple in-process HTTP/1.1 server
27: *
28: * @todo Add support for chunked transfers
29: *
30: * @api
31: */
32: class Server implements Immutable, HasHttpHeader, HasRequestMethod
33: {
34: use ImmutableTrait;
35:
36: private string $Host;
37: private int $Port;
38: private int $Timeout;
39: private string $ProxyHost;
40: private int $ProxyPort;
41: private bool $ProxyHasTls;
42: private string $ProxyPath;
43:
44: // --
45:
46: private string $Address;
47: /** @var resource|null */
48: private $Server = null;
49: private string $LocalIpAddress;
50: private int $LocalPort;
51:
52: /**
53: * @api
54: *
55: * @param int $timeout The number of seconds to wait for a request, or an
56: * integer less than `0` to wait indefinitely. May be overridden via
57: * {@see listen()}.
58: */
59: public function __construct(string $host, int $port, int $timeout = -1)
60: {
61: $this->Host = $host;
62: $this->Port = $port;
63: $this->Timeout = $timeout;
64: }
65:
66: /**
67: * @internal
68: */
69: protected function __clone()
70: {
71: unset($this->Address);
72: $this->Server = null;
73: unset($this->LocalIpAddress);
74: unset($this->LocalPort);
75: }
76:
77: /**
78: * Get the hostname or IP address of the server
79: */
80: public function getHost(): string
81: {
82: return $this->Host;
83: }
84:
85: /**
86: * Get the TCP port of the server
87: */
88: public function getPort(): int
89: {
90: return $this->Port;
91: }
92:
93: /**
94: * Get the number of seconds the server will wait for a request
95: *
96: * If an integer less than `0` is returned, the server will wait
97: * indefinitely.
98: */
99: public function getTimeout(): int
100: {
101: return $this->Timeout;
102: }
103:
104: /**
105: * Check if the server is configured to run behind a proxy server
106: */
107: public function hasProxy(): bool
108: {
109: return isset($this->ProxyHost);
110: }
111:
112: /**
113: * Get the hostname or IP address of the proxy server
114: *
115: * @throws LogicException if the server is not configured to run behind a
116: * proxy server.
117: */
118: public function getProxyHost(): string
119: {
120: $this->assertHasProxy();
121: return $this->ProxyHost;
122: }
123:
124: /**
125: * Get the TCP port of the proxy server
126: *
127: * @throws LogicException if the server is not configured to run behind a
128: * proxy server.
129: */
130: public function getProxyPort(): int
131: {
132: $this->assertHasProxy();
133: return $this->ProxyPort;
134: }
135:
136: /**
137: * Check if the proxy server uses TLS
138: *
139: * @throws LogicException if the server is not configured to run behind a
140: * proxy server.
141: */
142: public function proxyHasTls(): bool
143: {
144: $this->assertHasProxy();
145: return $this->ProxyHasTls;
146: }
147:
148: /**
149: * Get the path at which the server can be reached via the proxy server
150: *
151: * @throws LogicException if the server is not configured to run behind a
152: * proxy server.
153: */
154: public function getProxyPath(): string
155: {
156: $this->assertHasProxy();
157: return $this->ProxyPath;
158: }
159:
160: /**
161: * Get an instance configured to run behind a proxy server
162: *
163: * Returns a server that listens at the same host and port, but refers to
164: * itself in client-facing URIs as:
165: *
166: * ```
167: * http[s]://<proxy_host>[:<proxy_port>][<proxy_path>]
168: * ```
169: *
170: * @return static
171: */
172: public function withProxy(
173: string $host,
174: int $port,
175: ?bool $hasTls = null,
176: string $path = ''
177: ): self {
178: $hasTls ??= $port === 443;
179: $path = trim($path, '/');
180: if ($path !== '') {
181: $path = '/' . $path;
182: }
183:
184: return $this
185: ->with('ProxyHost', $host)
186: ->with('ProxyPort', $port)
187: ->with('ProxyHasTls', $hasTls)
188: ->with('ProxyPath', $path);
189: }
190:
191: /**
192: * Get an instance configured to run without a proxy server
193: *
194: * @return static
195: */
196: public function withoutProxy(): self
197: {
198: return $this
199: ->without('ProxyHost')
200: ->without('ProxyPort')
201: ->without('ProxyHasTls')
202: ->without('ProxyPath');
203: }
204:
205: /**
206: * Get the client-facing URI of the server, with no trailing slash
207: *
208: * Call this method after {@see start()} if using dynamic port allocation.
209: */
210: public function getUri(): Uri
211: {
212: return isset($this->ProxyHost)
213: ? (new Uri())
214: ->withScheme($this->ProxyHasTls ? 'https' : 'http')
215: ->withHost($this->ProxyHost)
216: ->withPort($this->ProxyPort)
217: ->withPath($this->ProxyPath)
218: : (new Uri())
219: ->withScheme('http')
220: ->withHost($this->Host)
221: ->withPort($this->LocalPort ?? $this->Port);
222: }
223:
224: /**
225: * Check if the server is running
226: */
227: public function isRunning(): bool
228: {
229: return (bool) $this->Server;
230: }
231:
232: /**
233: * Get the IP address to which the server is bound
234: *
235: * @throws LogicException if the server is not running.
236: */
237: public function getLocalIpAddress(): string
238: {
239: $this->assertIsRunning();
240: return $this->LocalIpAddress;
241: }
242:
243: /**
244: * Get the TCP port on which the server is listening
245: *
246: * @throws LogicException if the server is not running.
247: */
248: public function getLocalPort(): int
249: {
250: $this->assertIsRunning();
251: return $this->LocalPort;
252: }
253:
254: /**
255: * Start the server
256: *
257: * @return $this
258: * @throws LogicException if the server is already running.
259: */
260: public function start(): self
261: {
262: $this->assertIsNotRunning();
263:
264: $address = $this->Address ??= sprintf(
265: 'tcp://%s:%d',
266: $this->Host,
267: $this->Port,
268: );
269:
270: $errorCode = null;
271: $errorMessage = null;
272: $server = @stream_socket_server($address, $errorCode, $errorMessage);
273:
274: if ($server === false) {
275: throw new ServerException(sprintf(
276: 'Error starting server at %s (%d: %s)',
277: $address,
278: $errorCode,
279: $errorMessage,
280: ));
281: }
282:
283: $address = @stream_socket_get_name($server, false);
284:
285: if ($address === false || ($pos = strrpos($address, ':')) === false) {
286: throw new ServerException('Error getting server address');
287: }
288:
289: $this->Server = $server;
290: $this->LocalIpAddress = substr($address, 0, $pos);
291: $this->LocalPort = (int) substr($address, $pos + 1);
292:
293: return $this;
294: }
295:
296: /**
297: * Stop the server if it is running
298: *
299: * @return $this
300: */
301: public function stop(): self
302: {
303: if ($this->Server) {
304: File::close($this->Server, $this->Address);
305: $this->Server = null;
306: unset($this->LocalIpAddress);
307: unset($this->LocalPort);
308: }
309:
310: return $this;
311: }
312:
313: /**
314: * Wait for a request and provide the response returned by a listener
315: *
316: * If the listener returns a response with a return value, the server stops
317: * listening for requests, ignoring `$limit` if given, and returns the value
318: * to the caller.
319: *
320: * @template TReturn
321: *
322: * @param callable(ServerRequestInterface $request): ServerResponse<TReturn> $listener
323: * @param int<-1,max> $limit If `-1` (the default), listen for requests
324: * indefinitely. Otherwise, listen until `$limit` requests have been
325: * received before returning `null`.
326: * @param bool $catchBadRequests If `false`, throw the underlying exception
327: * when an invalid request is rejected.
328: * @param int|null $timeout The number of seconds to wait for a request, an
329: * integer less than `0` to wait indefinitely, or `null` (the default) to
330: * use the server's default timeout.
331: * @param bool $strict If `true`, strict \[RFC9112] compliance is enforced.
332: * @return TReturn|null
333: * @throws LogicException if the server is not running.
334: */
335: public function listen(
336: callable $listener,
337: int $limit = -1,
338: bool $catchBadRequests = true,
339: ?int $timeout = null,
340: bool $strict = false
341: ) {
342: $this->assertIsRunning();
343:
344: while ($limit) {
345: $stream = null;
346: $response = null;
347: try {
348: $request = $this->getRequest($stream, $timeout, $strict);
349: $response = $listener($request);
350: if ($response->hasReturnValue()) {
351: return $response->getReturnValue();
352: }
353: } catch (InvalidHeaderExceptionInterface $ex) {
354: $response = new Response(
355: $ex->getStatusCode() ?? 400,
356: $ex->getMessage(),
357: );
358: if (!$catchBadRequests) {
359: throw $ex;
360: }
361: } catch (LogicException|ServerException $ex) {
362: throw $ex;
363: } catch (Throwable $ex) {
364: throw new ServerException($ex->getMessage(), $ex);
365: } finally {
366: if ($stream) {
367: if ($response) {
368: File::writeAll($stream, (string) $response);
369: }
370: File::close($stream);
371: }
372: }
373: $limit--;
374: }
375:
376: return null;
377: }
378:
379: /**
380: * @param resource|null $stream
381: * @param-out resource $stream
382: */
383: private function getRequest(&$stream, ?int $timeout, bool $strict): ServerRequest
384: {
385: $this->assertIsRunning();
386:
387: $handle = @stream_socket_accept(
388: $this->Server,
389: $timeout ?? $this->Timeout,
390: $client,
391: );
392:
393: if ($handle === false) {
394: $error = error_get_last();
395: throw new ServerException($error['message'] ?? sprintf(
396: 'Error accepting connection at %s',
397: $this->Address,
398: ));
399: }
400:
401: if ($client === null) {
402: throw new ServerException('No client address');
403: }
404:
405: Regex::match('/(?<addr>.*?)(?::(?<port>[0-9]++))?$/D', $client, $matches);
406:
407: /** @var array{addr:string,port?:string} $matches */
408: $client = $matches['addr'];
409: $serverParams = [
410: 'REMOTE_ADDR' => $matches['addr'],
411: 'REMOTE_PORT' => $matches['port'] ?? '',
412: ];
413:
414: // Get request line
415: $stream = $handle;
416: $line = File::readLine($stream);
417:
418: if ($strict && substr($line, -2) !== "\r\n") {
419: throw new InvalidHeaderException(
420: 'HTTP request line must end with CRLF',
421: );
422: }
423:
424: $line = $strict
425: ? substr($line, 0, -2)
426: : rtrim($line, "\r\n");
427: $startLine = $strict
428: ? explode(' ', $line, 4)
429: : Regex::split('/\s++/', trim($line), 3);
430:
431: $invalidMethod = false;
432: if (
433: count($startLine) !== 3
434: || !Regex::match('/^HTTP\/([0-9](?:\.[0-9])?)$/D', $startLine[2], $matches)
435: || ($invalidMethod = !HttpUtil::isRequestMethod($startLine[0]))
436: ) {
437: throw new InvalidHeaderException(sprintf(
438: 'Invalid HTTP request line: %s',
439: $line,
440: ), null, $invalidMethod ? 501 : null);
441: }
442:
443: $method = $startLine[0];
444: $requestTarget = $startLine[1];
445: $version = $matches[1];
446: $requestTargetUri = null;
447:
448: // Check request target as per [RFC9112] Section 3.2 ("Request Target")
449: if ((
450: ($isAsteriskForm = $requestTarget === '*')
451: && $method !== self::METHOD_OPTIONS
452: ) || (
453: ($isAuthorityForm = HttpUtil::isAuthorityForm($requestTarget))
454: && $method !== self::METHOD_CONNECT
455: )) {
456: throw new InvalidHeaderException(sprintf(
457: "Invalid HTTP method for request target '%s': %s",
458: $requestTarget,
459: $method,
460: ));
461: } elseif (
462: $method === self::METHOD_CONNECT
463: && !$isAuthorityForm
464: ) {
465: throw new InvalidHeaderException(sprintf(
466: "Invalid request target for HTTP method '%s': %s",
467: $method,
468: $requestTarget,
469: ));
470: } elseif ($isAuthorityForm) {
471: $requestTargetUri = new Uri('//' . $requestTarget);
472: } elseif (!$isAsteriskForm) {
473: try {
474: $requestTargetUri = new Uri($requestTarget, $strict);
475: } catch (InvalidArgumentException $ex) {
476: throw new InvalidHeaderException(sprintf(
477: 'Invalid HTTP request target: %s',
478: $requestTarget,
479: ), $ex);
480: }
481: $isAbsoluteForm = !$requestTargetUri->isRelativeReference();
482: $isOriginForm = !$isAbsoluteForm
483: && $requestTargetUri->getPath() !== ''
484: && !array_diff_key(
485: $requestTargetUri->getComponents(),
486: ['path' => null, 'query' => null],
487: );
488: if (!$isAbsoluteForm && !$isOriginForm) {
489: throw new InvalidHeaderException(sprintf(
490: 'Invalid HTTP request target: %s',
491: $requestTarget,
492: ));
493: }
494: }
495:
496: // Get header field lines
497: $headers = new Headers();
498: do {
499: $line = File::readLine($stream);
500: if ($line === '') {
501: throw new InvalidHeaderException('Invalid HTTP field lines');
502: }
503: $headers = $headers->addLine($line, $strict);
504: } while (!$headers->hasEmptyLine());
505:
506: // As per [RFC9112] Section 3.3 ("Reconstructing the Target URI")
507: $host = $headers->getOnlyHeaderValue(self::HEADER_HOST);
508: $uri = implode('', [
509: $this->getUri()->getScheme(),
510: '://',
511: Str::coalesce($host, $this->ProxyHost ?? $this->Host),
512: ]);
513: if (!Regex::match('/:[0-9]++$/', $uri)) {
514: $uri .= ':' . ($this->ProxyPort ?? $this->LocalPort);
515: }
516: try {
517: $uri = new Uri($uri, $strict);
518: } catch (InvalidArgumentException $ex) {
519: throw new InvalidHeaderException(sprintf(
520: 'Invalid HTTP request target: %s',
521: $uri,
522: ), $ex);
523: }
524: if ($requestTargetUri) {
525: $uri = $uri->follow($requestTargetUri);
526: }
527:
528: $length = HttpUtil::getContentLength($headers);
529: if ($length === 0) {
530: $body = '';
531: } elseif ($length !== null) {
532: $body = File::readAll($stream, $length);
533: } else {
534: $body = null;
535: }
536:
537: return new ServerRequest(
538: $method,
539: $uri,
540: $serverParams,
541: $body,
542: $headers,
543: $requestTarget,
544: $version,
545: );
546: }
547:
548: /**
549: * @phpstan-assert null $this->Server
550: */
551: private function assertIsNotRunning(): void
552: {
553: if ($this->Server) {
554: throw new LogicException('Server is running');
555: }
556: }
557:
558: /**
559: * @phpstan-assert !null $this->Server
560: */
561: private function assertIsRunning(): void
562: {
563: if (!$this->Server) {
564: throw new LogicException('Server is not running');
565: }
566: }
567:
568: private function assertHasProxy(): void
569: {
570: if (!isset($this->ProxyHost)) {
571: throw new LogicException('Server is not configured to run behind a proxy server');
572: }
573: }
574: }
575: