From f091bd265adfa8b3a0f4e37d9eab1e68b449cfef Mon Sep 17 00:00:00 2001 From: David Stone Date: Fri, 3 Apr 2026 18:28:41 -0600 Subject: [PATCH 1/2] =?UTF-8?q?Revert=20"refactor:=20simplify=20FrankenPHP?= =?UTF-8?q?=20integration=20=E2=80=94=20on-demand=20TLS=20handles=20everyt?= =?UTF-8?q?hing"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 078f7f0880536a9650cb6df7fd762703eda1fe13. --- .../class-frankenphp-domain-mapping.php | 104 ++++++++++++++++-- 1 file changed, 92 insertions(+), 12 deletions(-) diff --git a/inc/integrations/providers/frankenphp/class-frankenphp-domain-mapping.php b/inc/integrations/providers/frankenphp/class-frankenphp-domain-mapping.php index a64edc53..ff87447d 100644 --- a/inc/integrations/providers/frankenphp/class-frankenphp-domain-mapping.php +++ b/inc/integrations/providers/frankenphp/class-frankenphp-domain-mapping.php @@ -17,6 +17,7 @@ namespace WP_Ultimo\Integrations\Providers\FrankenPHP; +use Psr\Log\LogLevel; use WP_Ultimo\Integrations\Base_Capability_Module; use WP_Ultimo\Integrations\Capabilities\Domain_Mapping_Capability; @@ -186,44 +187,123 @@ private function is_valid_multisite_domain(string $domain): bool { * * @param string $domain The domain name being mapped. * @param int $site_id ID of the site receiving the mapping. - * On-demand TLS handles certificate provisioning automatically on first - * TLS handshake. The ask endpoint validates the domain. No explicit - * provisioning needed — just log for observability. - * * @return void */ public function on_add_domain(string $domain, int $site_id): void { - if (function_exists('wu_log_add')) { - wu_log_add('integration-frankenphp', "Domain added: {$domain} (cert will be provisioned on first visit via on-demand TLS)"); - } + $this->provision_certificate($domain); } /** - * {@inheritdoc} + * Called when a mapped domain is removed. + * + * Caddy automatically stops serving certs for domains that fail the + * "ask" check on renewal, so no explicit cleanup is needed. * - * Caddy automatically stops renewing certs for domains that fail the - * ask endpoint check, so no explicit cleanup is needed. + * @param string $domain The domain name being removed. + * @param int $site_id ID of the site. + * @return void */ public function on_remove_domain(string $domain, int $site_id): void { + // Caddy handles cleanup automatically via on-demand TLS renewal checks. if (function_exists('wu_log_add')) { wu_log_add('integration-frankenphp', "Domain removed: {$domain} (cert will expire naturally)"); } } /** - * {@inheritdoc} + * Called when a new subdomain is added. + * + * Subdomains under the main domain are covered by the wildcard cert + * or on-demand TLS, so no action needed. + * + * @param string $subdomain The subdomain being added. + * @param int $site_id ID of the site. + * @return void */ public function on_add_subdomain(string $subdomain, int $site_id): void { } /** - * {@inheritdoc} + * Called when a subdomain is removed. + * + * @param string $subdomain The subdomain being removed. + * @param int $site_id ID of the site. + * @return void */ public function on_remove_subdomain(string $subdomain, int $site_id): void { } + /** + * Add a Let's Encrypt TLS policy for a domain via Caddy's admin API. + * + * Caddy's Caddyfile adapter merges TLS policies when a catch-all block + * uses a static cert, so named blocks don't get their own ACME policy. + * This method patches the TLS connection policies at runtime to add + * an ACME-backed policy for the domain before the static-cert catch-all. + * + * @param string $domain The domain to provision. + * @return void + */ + private function provision_certificate(string $domain): void { + + /** @var FrankenPHP_Integration */ + $frankenphp = $this->get_integration(); + + // Get current TLS connection policies. + $current = $frankenphp->api_call( + '/config/apps/http/servers/srv0/tls_connection_policies', + [], + 'GET' + ); + + if (is_wp_error($current) || ! is_array($current)) { + if (function_exists('wu_log_add')) { + wu_log_add('integration-frankenphp', "Failed to read TLS policies for {$domain}", LogLevel::ERROR); + } + return; + } + + // Check if this domain already has a policy. + foreach ($current as $policy) { + $sni = $policy['match']['sni'] ?? []; + if (in_array($domain, $sni, true)) { + if (function_exists('wu_log_add')) { + wu_log_add('integration-frankenphp', "TLS policy already exists for {$domain}"); + } + return; + } + } + + // Insert a new ACME policy for this domain before the catch-all. + // Policies without certificate_selection use Caddy's default (ACME/Let's Encrypt). + $new_policy = ['match' => ['sni' => [$domain]]]; + + // Insert before the last entry (the catch-all with no SNI match). + array_splice($current, count($current) - 1, 0, [$new_policy]); + + $response = $frankenphp->api_call( + '/config/apps/http/servers/srv0/tls_connection_policies', + $current, + 'PATCH' + ); + + if (is_wp_error($response)) { + if (function_exists('wu_log_add')) { + wu_log_add( + 'integration-frankenphp', + "TLS policy error for {$domain}: " . $response->get_error_message(), + LogLevel::ERROR + ); + } + } else { + if (function_exists('wu_log_add')) { + wu_log_add('integration-frankenphp', "Let's Encrypt TLS policy added for {$domain}"); + } + } + } + /** * {@inheritdoc} */ From 22187311cb4b0b0c429c995aa13e5742028fa7ab Mon Sep 17 00:00:00 2001 From: David Stone Date: Fri, 3 Apr 2026 18:28:44 -0600 Subject: [PATCH 2/2] Revert "feat: add FrankenPHP/Caddy integration for automatic Let's Encrypt SSL" This reverts commit f775968d86f329fccfefaafe690d60ca1c529d95. --- .../class-integration-registry.php | 10 +- .../class-frankenphp-domain-mapping.php | 314 ------------------ .../class-frankenphp-integration.php | 139 -------- 3 files changed, 8 insertions(+), 455 deletions(-) delete mode 100644 inc/integrations/providers/frankenphp/class-frankenphp-domain-mapping.php delete mode 100644 inc/integrations/providers/frankenphp/class-frankenphp-integration.php diff --git a/inc/integrations/class-integration-registry.php b/inc/integrations/class-integration-registry.php index 02d5a1bf..c5a91038 100644 --- a/inc/integrations/class-integration-registry.php +++ b/inc/integrations/class-integration-registry.php @@ -128,10 +128,13 @@ private function register_core_integrations(): void { $this->register(new Providers\Cloudflare\Cloudflare_Integration()); $this->register(new Providers\Hestia\Hestia_Integration()); $this->register(new Providers\Enhance\Enhance_Integration()); + $this->register(new Providers\Plesk\Plesk_Integration()); $this->register(new Providers\Rocket\Rocket_Integration()); $this->register(new Providers\WPEngine\WPEngine_Integration()); $this->register(new Providers\WPMUDEV\WPMUDEV_Integration()); - $this->register(new Providers\FrankenPHP\FrankenPHP_Integration()); + $this->register(new Providers\BunnyNet\BunnyNet_Integration()); + $this->register(new Providers\LaravelForge\LaravelForge_Integration()); + $this->register(new Providers\Amazon_SES\Amazon_SES_Integration()); } /** @@ -174,10 +177,13 @@ private function register_core_capabilities(): void { $this->add_capability('cloudflare', new Providers\Cloudflare\Cloudflare_Domain_Mapping()); $this->add_capability('hestia', new Providers\Hestia\Hestia_Domain_Mapping()); $this->add_capability('enhance', new Providers\Enhance\Enhance_Domain_Mapping()); + $this->add_capability('plesk', new Providers\Plesk\Plesk_Domain_Mapping()); $this->add_capability('rocket', new Providers\Rocket\Rocket_Domain_Mapping()); $this->add_capability('wpengine', new Providers\WPEngine\WPEngine_Domain_Mapping()); $this->add_capability('wpmudev', new Providers\WPMUDEV\WPMUDEV_Domain_Mapping()); - $this->add_capability('frankenphp', new Providers\FrankenPHP\FrankenPHP_Domain_Mapping()); + $this->add_capability('bunnynet', new Providers\BunnyNet\BunnyNet_Domain_Mapping()); + $this->add_capability('laravel-forge', new Providers\LaravelForge\LaravelForge_Domain_Mapping()); + $this->add_capability('amazon-ses', new Providers\Amazon_SES\Amazon_SES_Transactional_Email()); } /** diff --git a/inc/integrations/providers/frankenphp/class-frankenphp-domain-mapping.php b/inc/integrations/providers/frankenphp/class-frankenphp-domain-mapping.php deleted file mode 100644 index ff87447d..00000000 --- a/inc/integrations/providers/frankenphp/class-frankenphp-domain-mapping.php +++ /dev/null @@ -1,314 +0,0 @@ - [ - __('Automatically provision Let\'s Encrypt SSL certificates for mapped domains via Caddy.', 'ultimate-multisite'), - __('Validate domain ownership via an internal "ask" endpoint before issuing certificates.', 'ultimate-multisite'), - ], - 'will_not' => [ - __('This integration does not manage DNS records. Domains must already point to this server.', 'ultimate-multisite'), - ], - ]; - } - - /** - * {@inheritdoc} - */ - public function register_hooks(): void { - - add_action('wu_add_domain', [$this, 'on_add_domain'], 10, 2); - add_action('wu_remove_domain', [$this, 'on_remove_domain'], 10, 2); - add_action('wu_add_subdomain', [$this, 'on_add_subdomain'], 10, 2); - add_action('wu_remove_subdomain', [$this, 'on_remove_subdomain'], 10, 2); - - // Register the "ask" endpoint for Caddy's on-demand TLS validation. - add_action('rest_api_init', [$this, 'register_ask_endpoint']); - } - - /** - * Register the REST endpoint that Caddy calls to validate domains - * before issuing on-demand TLS certificates. - * - * @return void - */ - public function register_ask_endpoint(): void { - - register_rest_route('wu-caddy/v1', '/ask', [ - 'methods' => \WP_REST_Server::READABLE, - 'callback' => [$this, 'handle_ask_request'], - 'permission_callback' => [$this, 'validate_ask_request'], - ]); - } - - /** - * Only allow requests from localhost (Caddy admin API). - * - * @param \WP_REST_Request $request The request. - * @return bool - */ - public function validate_ask_request(\WP_REST_Request $request): bool { - - $ip = $_SERVER['REMOTE_ADDR'] ?? ''; - - return in_array($ip, ['127.0.0.1', '::1', ''], true); - } - - /** - * Handle the "ask" request from Caddy's on-demand TLS. - * - * Caddy sends GET /wp-json/wu-caddy/v1/ask?domain=example.com - * Return 200 if the domain is valid, 403 otherwise. - * - * @param \WP_REST_Request $request The request. - * @return \WP_REST_Response - */ - public function handle_ask_request(\WP_REST_Request $request): \WP_REST_Response { - - $domain = sanitize_text_field($request->get_param('domain')); - - if (empty($domain)) { - return new \WP_REST_Response(['error' => 'missing domain'], 400); - } - - if ($this->is_valid_multisite_domain($domain)) { - return new \WP_REST_Response(['ok' => true], 200); - } - - return new \WP_REST_Response(['error' => 'unknown domain'], 403); - } - - /** - * Check if a domain belongs to this WordPress multisite. - * - * Checks both the wp_blogs table (subdomains) and the Ultimate Multisite - * domain mapping table. - * - * @param string $domain The domain to check. - * @return bool - */ - private function is_valid_multisite_domain(string $domain): bool { - - global $wpdb; - - // Check wp_blogs table (covers subdomains and primary domains). - $blog_id = $wpdb->get_var( - $wpdb->prepare( - "SELECT blog_id FROM {$wpdb->blogs} WHERE domain = %s LIMIT 1", - $domain - ) - ); - - if ($blog_id) { - return true; - } - - // Check Ultimate Multisite domain mapping table. - $table = $wpdb->base_prefix . 'wu_domain_mappings'; - - if ($wpdb->get_var("SHOW TABLES LIKE '{$table}'") === $table) { - $mapping_id = $wpdb->get_var( - $wpdb->prepare( - "SELECT id FROM {$table} WHERE domain = %s AND active = 1 LIMIT 1", - $domain - ) - ); - - if ($mapping_id) { - return true; - } - } - - return false; - } - - /** - * Called when a new domain is mapped. - * - * Pre-provisions a Let's Encrypt certificate via Caddy's admin API - * so the first visitor doesn't experience a TLS handshake delay. - * - * @param string $domain The domain name being mapped. - * @param int $site_id ID of the site receiving the mapping. - * @return void - */ - public function on_add_domain(string $domain, int $site_id): void { - - $this->provision_certificate($domain); - } - - /** - * Called when a mapped domain is removed. - * - * Caddy automatically stops serving certs for domains that fail the - * "ask" check on renewal, so no explicit cleanup is needed. - * - * @param string $domain The domain name being removed. - * @param int $site_id ID of the site. - * @return void - */ - public function on_remove_domain(string $domain, int $site_id): void { - - // Caddy handles cleanup automatically via on-demand TLS renewal checks. - if (function_exists('wu_log_add')) { - wu_log_add('integration-frankenphp', "Domain removed: {$domain} (cert will expire naturally)"); - } - } - - /** - * Called when a new subdomain is added. - * - * Subdomains under the main domain are covered by the wildcard cert - * or on-demand TLS, so no action needed. - * - * @param string $subdomain The subdomain being added. - * @param int $site_id ID of the site. - * @return void - */ - public function on_add_subdomain(string $subdomain, int $site_id): void { - } - - /** - * Called when a subdomain is removed. - * - * @param string $subdomain The subdomain being removed. - * @param int $site_id ID of the site. - * @return void - */ - public function on_remove_subdomain(string $subdomain, int $site_id): void { - } - - /** - * Add a Let's Encrypt TLS policy for a domain via Caddy's admin API. - * - * Caddy's Caddyfile adapter merges TLS policies when a catch-all block - * uses a static cert, so named blocks don't get their own ACME policy. - * This method patches the TLS connection policies at runtime to add - * an ACME-backed policy for the domain before the static-cert catch-all. - * - * @param string $domain The domain to provision. - * @return void - */ - private function provision_certificate(string $domain): void { - - /** @var FrankenPHP_Integration */ - $frankenphp = $this->get_integration(); - - // Get current TLS connection policies. - $current = $frankenphp->api_call( - '/config/apps/http/servers/srv0/tls_connection_policies', - [], - 'GET' - ); - - if (is_wp_error($current) || ! is_array($current)) { - if (function_exists('wu_log_add')) { - wu_log_add('integration-frankenphp', "Failed to read TLS policies for {$domain}", LogLevel::ERROR); - } - return; - } - - // Check if this domain already has a policy. - foreach ($current as $policy) { - $sni = $policy['match']['sni'] ?? []; - if (in_array($domain, $sni, true)) { - if (function_exists('wu_log_add')) { - wu_log_add('integration-frankenphp', "TLS policy already exists for {$domain}"); - } - return; - } - } - - // Insert a new ACME policy for this domain before the catch-all. - // Policies without certificate_selection use Caddy's default (ACME/Let's Encrypt). - $new_policy = ['match' => ['sni' => [$domain]]]; - - // Insert before the last entry (the catch-all with no SNI match). - array_splice($current, count($current) - 1, 0, [$new_policy]); - - $response = $frankenphp->api_call( - '/config/apps/http/servers/srv0/tls_connection_policies', - $current, - 'PATCH' - ); - - if (is_wp_error($response)) { - if (function_exists('wu_log_add')) { - wu_log_add( - 'integration-frankenphp', - "TLS policy error for {$domain}: " . $response->get_error_message(), - LogLevel::ERROR - ); - } - } else { - if (function_exists('wu_log_add')) { - wu_log_add('integration-frankenphp', "Let's Encrypt TLS policy added for {$domain}"); - } - } - } - - /** - * {@inheritdoc} - */ - public function test_connection() { - - return $this->get_integration()->test_connection(); - } -} diff --git a/inc/integrations/providers/frankenphp/class-frankenphp-integration.php b/inc/integrations/providers/frankenphp/class-frankenphp-integration.php deleted file mode 100644 index 7a452309..00000000 --- a/inc/integrations/providers/frankenphp/class-frankenphp-integration.php +++ /dev/null @@ -1,139 +0,0 @@ -set_description( - __('FrankenPHP (Caddy) integration with automatic Let\'s Encrypt SSL for mapped domains.', 'ultimate-multisite') - ); - $this->set_logo(function_exists('wu_get_asset') ? wu_get_asset('frankenphp.svg', 'img/hosts') : ''); - $this->set_supports(['autossl', 'no-instructions', 'no-config']); - } - - /** - * Auto-detect FrankenPHP by checking for the FRANKENPHP_WORKER constant - * or the frankenphp SAPI name. - * - * @return bool - */ - public function detect(): bool { - - // FrankenPHP sets this in worker mode. - if (defined('FRANKENPHP_WORKER') || php_sapi_name() === 'frankenphp') { - return true; - } - - // Fallback: check if Caddy admin API is reachable. - $response = wp_remote_get($this->admin_api . '/config/', [ - 'timeout' => 2, - 'sslverify' => false, - ]); - - return ! is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200; - } - - /** - * Test connection to the Caddy admin API. - * - * @return true|\WP_Error - */ - public function test_connection() { - - $response = wp_remote_get($this->admin_api . '/config/', [ - 'timeout' => 5, - 'sslverify' => false, - ]); - - if (is_wp_error($response)) { - return $response; - } - - $code = wp_remote_retrieve_response_code($response); - - if ($code !== 200) { - return new \WP_Error( - 'caddy_api_error', - sprintf(__('Caddy admin API returned HTTP %d', 'ultimate-multisite'), $code) - ); - } - - return true; - } - - /** - * Get the Caddy admin API base URL. - * - * @return string - */ - public function get_admin_api(): string { - - return $this->admin_api; - } - - /** - * Send a request to the Caddy admin API. - * - * @param string $endpoint API endpoint path. - * @param array $body Request body (will be JSON-encoded). - * @param string $method HTTP method. - * @return array|\WP_Error Decoded response or error. - */ - public function api_call(string $endpoint, array $body = [], string $method = 'POST') { - - $args = [ - 'method' => $method, - 'timeout' => 30, - 'sslverify' => false, - 'headers' => ['Content-Type' => 'application/json'], - ]; - - if (! empty($body)) { - $args['body'] = wp_json_encode($body); - } - - $response = wp_remote_request($this->admin_api . $endpoint, $args); - - if (is_wp_error($response)) { - return $response; - } - - $decoded = json_decode(wp_remote_retrieve_body($response), true); - - return is_array($decoded) ? $decoded : []; - } -}