diff --git a/app/Factory.php b/app/Factory.php index 58bddb0..7c5949e 100644 --- a/app/Factory.php +++ b/app/Factory.php @@ -33,6 +33,7 @@ use Expose\Server\Http\Controllers\Admin\StoreSubdomainController; use Expose\Server\Http\Controllers\Admin\StoreUsersController; use Expose\Server\Http\Controllers\Admin\UpdateDomainController; +use Expose\Server\Http\Controllers\CanIssueCertificateController; use Expose\Server\Http\Controllers\ControlMessageController; use Expose\Server\Http\Controllers\HealthController; use Expose\Server\Http\Controllers\TunnelMessageController; @@ -137,6 +138,17 @@ protected function addControlConnectionRoute(): WsServer return $wsServer; } + protected function addCertificateAuthorizationRoute() + { + // Caddy's on-demand TLS asks this endpoint before issuing a certificate. + // Gated to the loopback interface so it can only be reached by the local + // Caddy process - this keeps it from shadowing a tunnelled app's path and + // from leaking which tunnels are live to external callers. + $loopbackCondition = 'request.headers.get("Host") in ["127.0.0.1", "127.0.0.1:'.$this->port.'"]'; + + $this->router->get('/expose/can-issue-certificate', CanIssueCertificateController::class, $loopbackCondition); + } + protected function addAdminRoutes() { $adminCondition = 'request.headers.get("Host") matches "/^'.config('expose-server.subdomain').'\\\\./i"'; @@ -219,6 +231,8 @@ public function createServer() $controlConnection = $this->addControlConnectionRoute(); + $this->addCertificateAuthorizationRoute(); + $this->addTunnelRoute(); $urlMatcher = new UrlMatcher($this->router->getRoutes(), new RequestContext); diff --git a/app/Http/Controllers/CanIssueCertificateController.php b/app/Http/Controllers/CanIssueCertificateController.php new file mode 100644 index 0000000..d31a8ac --- /dev/null +++ b/app/Http/Controllers/CanIssueCertificateController.php @@ -0,0 +1,41 @@ +get('domain'); + + if (blank($domain)) { + $httpConnection->send(respond_html('Missing domain', 400)); + + return; + } + + // Parse the host the same way TunnelMessageController routes a request, + // so the certificate decision matches whether a request for this host + // would actually be served by a live tunnel. + $serverHost = Str::before(Str::after($domain, '.'), ':'); + $subdomain = Str::before($domain, '.'.$serverHost); + + /** @var ConnectionManager $connectionManager */ + $connectionManager = app(ConnectionManager::class); + + $hasActiveTunnel = $subdomain !== $domain + && $connectionManager->findControlConnectionForSubdomainAndServerHost($subdomain, $serverHost) !== null; + + // Caddy on-demand TLS treats any 2xx as "issue", anything else as "refuse". + $httpConnection->send(respond_html( + $hasActiveTunnel ? 'OK' : 'No active tunnel', + $hasActiveTunnel ? 200 : 404 + )); + } +} diff --git a/tests/Feature/Server/TunnelTest.php b/tests/Feature/Server/TunnelTest.php index 6650ad1..b92eed6 100644 --- a/tests/Feature/Server/TunnelTest.php +++ b/tests/Feature/Server/TunnelTest.php @@ -181,6 +181,34 @@ public function it_can_update_404_pages_for_custom_domains() } } + /** @test */ + public function it_refuses_certificate_issuance_for_a_host_without_an_active_tunnel() + { + $this->expectException(ResponseException::class); + $this->expectExceptionMessage(404); + + $this->await($this->browser->get('http://127.0.0.1:8080/expose/can-issue-certificate?domain=tunnel.localhost', [ + 'Host' => '127.0.0.1:8080', + ])); + } + + /** @test */ + public function it_allows_certificate_issuance_for_a_host_with_an_active_tunnel() + { + $this->app['config']['expose-server.validate_auth_tokens'] = false; + + $this->createTestHttpServer(); + + $client = $this->createClient(); + $this->await($client->connectToServer('127.0.0.1:8085', 'tunnel')); + + $response = $this->await($this->browser->get('http://127.0.0.1:8080/expose/can-issue-certificate?domain=tunnel.localhost', [ + 'Host' => '127.0.0.1:8080', + ])); + + $this->assertSame(200, $response->getStatusCode()); + } + /** @test */ public function it_sends_incoming_requests_to_the_connected_client() {