Skip to content

Commit f775968

Browse files
committed
feat: add FrankenPHP/Caddy integration for automatic Let's Encrypt SSL
- New integration provider: FrankenPHP (auto-detected via SAPI name or Caddy admin API) - Domain mapping capability: patches Caddy TLS connection policies via admin API to provision Let's Encrypt certs for grey-clouded/direct domains - REST ask endpoint for on-demand TLS domain validation (checks wp_blogs + domain mappings) - Works alongside static-cert catch-all for Cloudflare-proxied domains
1 parent 39a1bbd commit f775968

3 files changed

Lines changed: 455 additions & 8 deletions

File tree

inc/integrations/class-integration-registry.php

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -128,13 +128,10 @@ private function register_core_integrations(): void {
128128
$this->register(new Providers\Cloudflare\Cloudflare_Integration());
129129
$this->register(new Providers\Hestia\Hestia_Integration());
130130
$this->register(new Providers\Enhance\Enhance_Integration());
131-
$this->register(new Providers\Plesk\Plesk_Integration());
132131
$this->register(new Providers\Rocket\Rocket_Integration());
133132
$this->register(new Providers\WPEngine\WPEngine_Integration());
134133
$this->register(new Providers\WPMUDEV\WPMUDEV_Integration());
135-
$this->register(new Providers\BunnyNet\BunnyNet_Integration());
136-
$this->register(new Providers\LaravelForge\LaravelForge_Integration());
137-
$this->register(new Providers\Amazon_SES\Amazon_SES_Integration());
134+
$this->register(new Providers\FrankenPHP\FrankenPHP_Integration());
138135
}
139136

140137
/**
@@ -177,13 +174,10 @@ private function register_core_capabilities(): void {
177174
$this->add_capability('cloudflare', new Providers\Cloudflare\Cloudflare_Domain_Mapping());
178175
$this->add_capability('hestia', new Providers\Hestia\Hestia_Domain_Mapping());
179176
$this->add_capability('enhance', new Providers\Enhance\Enhance_Domain_Mapping());
180-
$this->add_capability('plesk', new Providers\Plesk\Plesk_Domain_Mapping());
181177
$this->add_capability('rocket', new Providers\Rocket\Rocket_Domain_Mapping());
182178
$this->add_capability('wpengine', new Providers\WPEngine\WPEngine_Domain_Mapping());
183179
$this->add_capability('wpmudev', new Providers\WPMUDEV\WPMUDEV_Domain_Mapping());
184-
$this->add_capability('bunnynet', new Providers\BunnyNet\BunnyNet_Domain_Mapping());
185-
$this->add_capability('laravel-forge', new Providers\LaravelForge\LaravelForge_Domain_Mapping());
186-
$this->add_capability('amazon-ses', new Providers\Amazon_SES\Amazon_SES_Transactional_Email());
180+
$this->add_capability('frankenphp', new Providers\FrankenPHP\FrankenPHP_Domain_Mapping());
187181
}
188182

189183
/**
Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
<?php
2+
/**
3+
* FrankenPHP Domain Mapping Capability.
4+
*
5+
* Uses Caddy's on-demand TLS for automatic Let's Encrypt certificates.
6+
* When a domain is added via Ultimate Multisite, this module pre-provisions
7+
* the certificate via Caddy's admin API so the first visitor doesn't wait.
8+
*
9+
* Caddy's on-demand TLS calls an "ask" endpoint to validate domains before
10+
* issuing certificates. This module registers a lightweight REST endpoint
11+
* that checks the WordPress multisite domain tables.
12+
*
13+
* @package WP_Ultimo
14+
* @subpackage Integrations/Providers/FrankenPHP
15+
* @since 2.5.0
16+
*/
17+
18+
namespace WP_Ultimo\Integrations\Providers\FrankenPHP;
19+
20+
use Psr\Log\LogLevel;
21+
use WP_Ultimo\Integrations\Base_Capability_Module;
22+
use WP_Ultimo\Integrations\Capabilities\Domain_Mapping_Capability;
23+
24+
defined('ABSPATH') || exit;
25+
26+
/**
27+
* FrankenPHP domain mapping capability module.
28+
*
29+
* @since 2.5.0
30+
*/
31+
class FrankenPHP_Domain_Mapping extends Base_Capability_Module implements Domain_Mapping_Capability {
32+
33+
/**
34+
* Supported features.
35+
*
36+
* @var array
37+
*/
38+
protected array $supported_features = ['autossl'];
39+
40+
/**
41+
* {@inheritdoc}
42+
*/
43+
public function get_capability_id(): string {
44+
45+
return 'domain-mapping';
46+
}
47+
48+
/**
49+
* {@inheritdoc}
50+
*/
51+
public function get_title(): string {
52+
53+
return __('Domain Mapping', 'ultimate-multisite');
54+
}
55+
56+
/**
57+
* {@inheritdoc}
58+
*/
59+
public function get_explainer_lines(): array {
60+
61+
return [
62+
'will' => [
63+
__('Automatically provision Let\'s Encrypt SSL certificates for mapped domains via Caddy.', 'ultimate-multisite'),
64+
__('Validate domain ownership via an internal "ask" endpoint before issuing certificates.', 'ultimate-multisite'),
65+
],
66+
'will_not' => [
67+
__('This integration does not manage DNS records. Domains must already point to this server.', 'ultimate-multisite'),
68+
],
69+
];
70+
}
71+
72+
/**
73+
* {@inheritdoc}
74+
*/
75+
public function register_hooks(): void {
76+
77+
add_action('wu_add_domain', [$this, 'on_add_domain'], 10, 2);
78+
add_action('wu_remove_domain', [$this, 'on_remove_domain'], 10, 2);
79+
add_action('wu_add_subdomain', [$this, 'on_add_subdomain'], 10, 2);
80+
add_action('wu_remove_subdomain', [$this, 'on_remove_subdomain'], 10, 2);
81+
82+
// Register the "ask" endpoint for Caddy's on-demand TLS validation.
83+
add_action('rest_api_init', [$this, 'register_ask_endpoint']);
84+
}
85+
86+
/**
87+
* Register the REST endpoint that Caddy calls to validate domains
88+
* before issuing on-demand TLS certificates.
89+
*
90+
* @return void
91+
*/
92+
public function register_ask_endpoint(): void {
93+
94+
register_rest_route('wu-caddy/v1', '/ask', [
95+
'methods' => \WP_REST_Server::READABLE,
96+
'callback' => [$this, 'handle_ask_request'],
97+
'permission_callback' => [$this, 'validate_ask_request'],
98+
]);
99+
}
100+
101+
/**
102+
* Only allow requests from localhost (Caddy admin API).
103+
*
104+
* @param \WP_REST_Request $request The request.
105+
* @return bool
106+
*/
107+
public function validate_ask_request(\WP_REST_Request $request): bool {
108+
109+
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
110+
111+
return in_array($ip, ['127.0.0.1', '::1', ''], true);
112+
}
113+
114+
/**
115+
* Handle the "ask" request from Caddy's on-demand TLS.
116+
*
117+
* Caddy sends GET /wp-json/wu-caddy/v1/ask?domain=example.com
118+
* Return 200 if the domain is valid, 403 otherwise.
119+
*
120+
* @param \WP_REST_Request $request The request.
121+
* @return \WP_REST_Response
122+
*/
123+
public function handle_ask_request(\WP_REST_Request $request): \WP_REST_Response {
124+
125+
$domain = sanitize_text_field($request->get_param('domain'));
126+
127+
if (empty($domain)) {
128+
return new \WP_REST_Response(['error' => 'missing domain'], 400);
129+
}
130+
131+
if ($this->is_valid_multisite_domain($domain)) {
132+
return new \WP_REST_Response(['ok' => true], 200);
133+
}
134+
135+
return new \WP_REST_Response(['error' => 'unknown domain'], 403);
136+
}
137+
138+
/**
139+
* Check if a domain belongs to this WordPress multisite.
140+
*
141+
* Checks both the wp_blogs table (subdomains) and the Ultimate Multisite
142+
* domain mapping table.
143+
*
144+
* @param string $domain The domain to check.
145+
* @return bool
146+
*/
147+
private function is_valid_multisite_domain(string $domain): bool {
148+
149+
global $wpdb;
150+
151+
// Check wp_blogs table (covers subdomains and primary domains).
152+
$blog_id = $wpdb->get_var(
153+
$wpdb->prepare(
154+
"SELECT blog_id FROM {$wpdb->blogs} WHERE domain = %s LIMIT 1",
155+
$domain
156+
)
157+
);
158+
159+
if ($blog_id) {
160+
return true;
161+
}
162+
163+
// Check Ultimate Multisite domain mapping table.
164+
$table = $wpdb->base_prefix . 'wu_domain_mappings';
165+
166+
if ($wpdb->get_var("SHOW TABLES LIKE '{$table}'") === $table) {
167+
$mapping_id = $wpdb->get_var(
168+
$wpdb->prepare(
169+
"SELECT id FROM {$table} WHERE domain = %s AND active = 1 LIMIT 1",
170+
$domain
171+
)
172+
);
173+
174+
if ($mapping_id) {
175+
return true;
176+
}
177+
}
178+
179+
return false;
180+
}
181+
182+
/**
183+
* Called when a new domain is mapped.
184+
*
185+
* Pre-provisions a Let's Encrypt certificate via Caddy's admin API
186+
* so the first visitor doesn't experience a TLS handshake delay.
187+
*
188+
* @param string $domain The domain name being mapped.
189+
* @param int $site_id ID of the site receiving the mapping.
190+
* @return void
191+
*/
192+
public function on_add_domain(string $domain, int $site_id): void {
193+
194+
$this->provision_certificate($domain);
195+
}
196+
197+
/**
198+
* Called when a mapped domain is removed.
199+
*
200+
* Caddy automatically stops serving certs for domains that fail the
201+
* "ask" check on renewal, so no explicit cleanup is needed.
202+
*
203+
* @param string $domain The domain name being removed.
204+
* @param int $site_id ID of the site.
205+
* @return void
206+
*/
207+
public function on_remove_domain(string $domain, int $site_id): void {
208+
209+
// Caddy handles cleanup automatically via on-demand TLS renewal checks.
210+
if (function_exists('wu_log_add')) {
211+
wu_log_add('integration-frankenphp', "Domain removed: {$domain} (cert will expire naturally)");
212+
}
213+
}
214+
215+
/**
216+
* Called when a new subdomain is added.
217+
*
218+
* Subdomains under the main domain are covered by the wildcard cert
219+
* or on-demand TLS, so no action needed.
220+
*
221+
* @param string $subdomain The subdomain being added.
222+
* @param int $site_id ID of the site.
223+
* @return void
224+
*/
225+
public function on_add_subdomain(string $subdomain, int $site_id): void {
226+
}
227+
228+
/**
229+
* Called when a subdomain is removed.
230+
*
231+
* @param string $subdomain The subdomain being removed.
232+
* @param int $site_id ID of the site.
233+
* @return void
234+
*/
235+
public function on_remove_subdomain(string $subdomain, int $site_id): void {
236+
}
237+
238+
/**
239+
* Add a Let's Encrypt TLS policy for a domain via Caddy's admin API.
240+
*
241+
* Caddy's Caddyfile adapter merges TLS policies when a catch-all block
242+
* uses a static cert, so named blocks don't get their own ACME policy.
243+
* This method patches the TLS connection policies at runtime to add
244+
* an ACME-backed policy for the domain before the static-cert catch-all.
245+
*
246+
* @param string $domain The domain to provision.
247+
* @return void
248+
*/
249+
private function provision_certificate(string $domain): void {
250+
251+
/** @var FrankenPHP_Integration */
252+
$frankenphp = $this->get_integration();
253+
254+
// Get current TLS connection policies.
255+
$current = $frankenphp->api_call(
256+
'/config/apps/http/servers/srv0/tls_connection_policies',
257+
[],
258+
'GET'
259+
);
260+
261+
if (is_wp_error($current) || ! is_array($current)) {
262+
if (function_exists('wu_log_add')) {
263+
wu_log_add('integration-frankenphp', "Failed to read TLS policies for {$domain}", LogLevel::ERROR);
264+
}
265+
return;
266+
}
267+
268+
// Check if this domain already has a policy.
269+
foreach ($current as $policy) {
270+
$sni = $policy['match']['sni'] ?? [];
271+
if (in_array($domain, $sni, true)) {
272+
if (function_exists('wu_log_add')) {
273+
wu_log_add('integration-frankenphp', "TLS policy already exists for {$domain}");
274+
}
275+
return;
276+
}
277+
}
278+
279+
// Insert a new ACME policy for this domain before the catch-all.
280+
// Policies without certificate_selection use Caddy's default (ACME/Let's Encrypt).
281+
$new_policy = ['match' => ['sni' => [$domain]]];
282+
283+
// Insert before the last entry (the catch-all with no SNI match).
284+
array_splice($current, count($current) - 1, 0, [$new_policy]);
285+
286+
$response = $frankenphp->api_call(
287+
'/config/apps/http/servers/srv0/tls_connection_policies',
288+
$current,
289+
'PATCH'
290+
);
291+
292+
if (is_wp_error($response)) {
293+
if (function_exists('wu_log_add')) {
294+
wu_log_add(
295+
'integration-frankenphp',
296+
"TLS policy error for {$domain}: " . $response->get_error_message(),
297+
LogLevel::ERROR
298+
);
299+
}
300+
} else {
301+
if (function_exists('wu_log_add')) {
302+
wu_log_add('integration-frankenphp', "Let's Encrypt TLS policy added for {$domain}");
303+
}
304+
}
305+
}
306+
307+
/**
308+
* {@inheritdoc}
309+
*/
310+
public function test_connection() {
311+
312+
return $this->get_integration()->test_connection();
313+
}
314+
}

0 commit comments

Comments
 (0)