Skip to content

Commit d1c1ba0

Browse files
committed
Allow on-demand certs for the server's own infrastructure hosts
The on-demand TLS ask endpoint refused the bare server hostname and the admin subdomain because neither is backed by a tunnel - but the wildcard cert covers *.host, not the host itself, so the server host reaches the on-demand path and broke the control-connection TLS handshake on a canary deploy. Always allow the server hostname and admin subdomain (mirroring the previous platform ask), plus any hosts listed under the new on_demand_tls.always_allow_hosts config, in addition to hosts with a live tunnel. Adds tests for each case and trims the controller doc-comment (full docs live in the SSL docs). Inert until a Caddyfile points its on-demand ask at the endpoint, so existing setups are unaffected.
1 parent 0d95717 commit d1c1ba0

3 files changed

Lines changed: 92 additions & 13 deletions

File tree

‎app/Http/Controllers/CanIssueCertificateController.php‎

Lines changed: 42 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,17 @@
33
namespace Expose\Server\Http\Controllers;
44

55
use Expose\Common\Http\Controllers\Controller;
6+
use Expose\Server\Configuration;
67
use Expose\Server\Contracts\ConnectionManager;
78
use Illuminate\Http\Request;
89
use Illuminate\Support\Str;
910
use Ratchet\ConnectionInterface;
1011

12+
/**
13+
* Answers Caddy's on-demand TLS "ask" endpoint, permitting certificate
14+
* issuance only for the server's infrastructure hosts or a host with a live
15+
* tunnel. See the SSL documentation for how to wire up the Caddyfile.
16+
*/
1117
class CanIssueCertificateController extends Controller
1218
{
1319
public function handle(Request $request, ConnectionInterface $httpConnection)
@@ -20,22 +26,45 @@ public function handle(Request $request, ConnectionInterface $httpConnection)
2026
return;
2127
}
2228

23-
// Parse the host the same way TunnelMessageController routes a request,
24-
// so the certificate decision matches whether a request for this host
25-
// would actually be served by a live tunnel.
26-
$serverHost = Str::before(Str::after($domain, '.'), ':');
27-
$subdomain = Str::before($domain, '.'.$serverHost);
28-
29-
/** @var ConnectionManager $connectionManager */
30-
$connectionManager = app(ConnectionManager::class);
31-
32-
$hasActiveTunnel = $subdomain !== $domain
33-
&& $connectionManager->findControlConnectionForSubdomainAndServerHost($subdomain, $serverHost) !== null;
29+
$allowed = $this->isInfrastructureHost($domain) || $this->hasActiveTunnel($domain);
3430

3531
// Caddy on-demand TLS treats any 2xx as "issue", anything else as "refuse".
3632
$httpConnection->send(respond_html(
37-
$hasActiveTunnel ? 'OK' : 'No active tunnel',
38-
$hasActiveTunnel ? 200 : 404
33+
$allowed ? 'OK' : 'No active tunnel',
34+
$allowed ? 200 : 404
3935
));
4036
}
37+
38+
/**
39+
* Hosts that may obtain a certificate without a live tunnel: the bare
40+
* server hostname (the wildcard certificate covers *.host but not the host
41+
* itself, so it reaches the on-demand path), the admin dashboard subdomain,
42+
* and any host configured under on_demand_tls.always_allow_hosts.
43+
*/
44+
protected function isInfrastructureHost(string $domain): bool
45+
{
46+
$hostname = app(Configuration::class)->hostname();
47+
48+
return $domain === $hostname
49+
|| $domain === config('expose-server.subdomain').'.'.$hostname
50+
|| in_array($domain, config('expose-server.on_demand_tls.always_allow_hosts', []), true);
51+
}
52+
53+
/**
54+
* Whether a control connection for this host is currently registered. The
55+
* host is parsed exactly like TunnelMessageController routes a request, so
56+
* the certificate decision matches whether the request would be served.
57+
*/
58+
protected function hasActiveTunnel(string $domain): bool
59+
{
60+
$serverHost = Str::before(Str::after($domain, '.'), ':');
61+
$subdomain = Str::before($domain, '.'.$serverHost);
62+
63+
if ($subdomain === $domain) {
64+
return false;
65+
}
66+
67+
return app(ConnectionManager::class)
68+
->findControlConnectionForSubdomainAndServerHost($subdomain, $serverHost) !== null;
69+
}
4170
}

‎config/expose-server.php‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,22 @@
104104
*/
105105
'reserved_subdomains' => [],
106106

107+
/*
108+
|--------------------------------------------------------------------------
109+
| On-Demand TLS
110+
|--------------------------------------------------------------------------
111+
|
112+
| When you terminate TLS with Caddy's on-demand TLS, point its "ask"
113+
| endpoint at /expose/can-issue-certificate. The server then only permits
114+
| certificate issuance for its own host, the admin subdomain, or a host
115+
| that currently has a live tunnel. List any additional non-tunnel hosts
116+
| that should always be allowed to obtain a certificate below.
117+
|
118+
*/
119+
'on_demand_tls' => [
120+
'always_allow_hosts' => [],
121+
],
122+
107123
/*
108124
|--------------------------------------------------------------------------
109125
| Subdomain Generator

‎tests/Feature/Server/TunnelTest.php‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,40 @@ public function it_allows_certificate_issuance_for_a_host_with_an_active_tunnel(
209209
$this->assertSame(200, $response->getStatusCode());
210210
}
211211

212+
/** @test */
213+
public function it_allows_certificate_issuance_for_the_server_host()
214+
{
215+
// The bare server hostname is not tunnel-backed (the wildcard cert
216+
// covers *.host but not the host itself) and must always be allowed.
217+
$response = $this->await($this->browser->get('http://127.0.0.1:8080/expose/can-issue-certificate?domain=localhost', [
218+
'Host' => '127.0.0.1:8080',
219+
]));
220+
221+
$this->assertSame(200, $response->getStatusCode());
222+
}
223+
224+
/** @test */
225+
public function it_allows_certificate_issuance_for_the_admin_subdomain()
226+
{
227+
$response = $this->await($this->browser->get('http://127.0.0.1:8080/expose/can-issue-certificate?domain=expose.localhost', [
228+
'Host' => '127.0.0.1:8080',
229+
]));
230+
231+
$this->assertSame(200, $response->getStatusCode());
232+
}
233+
234+
/** @test */
235+
public function it_allows_certificate_issuance_for_configured_always_allow_hosts()
236+
{
237+
$this->app['config']['expose-server.on_demand_tls.always_allow_hosts'] = ['status.example.com'];
238+
239+
$response = $this->await($this->browser->get('http://127.0.0.1:8080/expose/can-issue-certificate?domain=status.example.com', [
240+
'Host' => '127.0.0.1:8080',
241+
]));
242+
243+
$this->assertSame(200, $response->getStatusCode());
244+
}
245+
212246
/** @test */
213247
public function it_sends_incoming_requests_to_the_connected_client()
214248
{

0 commit comments

Comments
 (0)