Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 42 additions & 13 deletions app/Http/Controllers/CanIssueCertificateController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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;
}
}
Binary file modified builds/expose-server
Binary file not shown.
16 changes: 16 additions & 0 deletions config/expose-server.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions tests/Feature/Server/TunnelTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Loading