diff --git a/app/Http/Controllers/CanIssueCertificateController.php b/app/Http/Controllers/CanIssueCertificateController.php index d31a8ac..4495705 100644 --- a/app/Http/Controllers/CanIssueCertificateController.php +++ b/app/Http/Controllers/CanIssueCertificateController.php @@ -3,11 +3,17 @@ namespace Expose\Server\Http\Controllers; use Expose\Common\Http\Controllers\Controller; +use Expose\Server\Configuration; use Expose\Server\Contracts\ConnectionManager; use Illuminate\Http\Request; use Illuminate\Support\Str; use Ratchet\ConnectionInterface; +/** + * Answers Caddy's on-demand TLS "ask" endpoint, permitting certificate + * issuance only for the server's infrastructure hosts or a host with a live + * tunnel. See the SSL documentation for how to wire up the Caddyfile. + */ class CanIssueCertificateController extends Controller { public function handle(Request $request, ConnectionInterface $httpConnection) @@ -20,22 +26,45 @@ public function handle(Request $request, ConnectionInterface $httpConnection) 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; + $allowed = $this->isInfrastructureHost($domain) || $this->hasActiveTunnel($domain); // 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 + $allowed ? 'OK' : 'No active tunnel', + $allowed ? 200 : 404 )); } + + /** + * Hosts that may obtain a certificate without a live tunnel: the bare + * server hostname (the wildcard certificate covers *.host but not the host + * itself, so it reaches the on-demand path), the admin dashboard subdomain, + * and any host configured under on_demand_tls.always_allow_hosts. + */ + protected function isInfrastructureHost(string $domain): bool + { + $hostname = app(Configuration::class)->hostname(); + + return $domain === $hostname + || $domain === config('expose-server.subdomain').'.'.$hostname + || in_array($domain, config('expose-server.on_demand_tls.always_allow_hosts', []), true); + } + + /** + * Whether a control connection for this host is currently registered. The + * host is parsed exactly like TunnelMessageController routes a request, so + * the certificate decision matches whether the request would be served. + */ + protected function hasActiveTunnel(string $domain): bool + { + $serverHost = Str::before(Str::after($domain, '.'), ':'); + $subdomain = Str::before($domain, '.'.$serverHost); + + if ($subdomain === $domain) { + return false; + } + + return app(ConnectionManager::class) + ->findControlConnectionForSubdomainAndServerHost($subdomain, $serverHost) !== null; + } } diff --git a/builds/expose-server b/builds/expose-server index 84550de..17827f4 100755 Binary files a/builds/expose-server and b/builds/expose-server differ diff --git a/config/expose-server.php b/config/expose-server.php index aa8352c..fb7c211 100644 --- a/config/expose-server.php +++ b/config/expose-server.php @@ -104,6 +104,22 @@ */ 'reserved_subdomains' => [], + /* + |-------------------------------------------------------------------------- + | On-Demand TLS + |-------------------------------------------------------------------------- + | + | When you terminate TLS with Caddy's on-demand TLS, point its "ask" + | endpoint at /expose/can-issue-certificate. The server then only permits + | certificate issuance for its own host, the admin subdomain, or a host + | that currently has a live tunnel. List any additional non-tunnel hosts + | that should always be allowed to obtain a certificate below. + | + */ + 'on_demand_tls' => [ + 'always_allow_hosts' => [], + ], + /* |-------------------------------------------------------------------------- | Subdomain Generator diff --git a/tests/Feature/Server/TunnelTest.php b/tests/Feature/Server/TunnelTest.php index b92eed6..526c3d1 100644 --- a/tests/Feature/Server/TunnelTest.php +++ b/tests/Feature/Server/TunnelTest.php @@ -209,6 +209,40 @@ public function it_allows_certificate_issuance_for_a_host_with_an_active_tunnel( $this->assertSame(200, $response->getStatusCode()); } + /** @test */ + public function it_allows_certificate_issuance_for_the_server_host() + { + // The bare server hostname is not tunnel-backed (the wildcard cert + // covers *.host but not the host itself) and must always be allowed. + $response = $this->await($this->browser->get('http://127.0.0.1:8080/expose/can-issue-certificate?domain=localhost', [ + 'Host' => '127.0.0.1:8080', + ])); + + $this->assertSame(200, $response->getStatusCode()); + } + + /** @test */ + public function it_allows_certificate_issuance_for_the_admin_subdomain() + { + $response = $this->await($this->browser->get('http://127.0.0.1:8080/expose/can-issue-certificate?domain=expose.localhost', [ + 'Host' => '127.0.0.1:8080', + ])); + + $this->assertSame(200, $response->getStatusCode()); + } + + /** @test */ + public function it_allows_certificate_issuance_for_configured_always_allow_hosts() + { + $this->app['config']['expose-server.on_demand_tls.always_allow_hosts'] = ['status.example.com']; + + $response = $this->await($this->browser->get('http://127.0.0.1:8080/expose/can-issue-certificate?domain=status.example.com', [ + 'Host' => '127.0.0.1:8080', + ])); + + $this->assertSame(200, $response->getStatusCode()); + } + /** @test */ public function it_sends_incoming_requests_to_the_connected_client() {