From fe5e06b558825d70b4dece3062e85830c000cb5c Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 9 Feb 2026 10:17:48 -0700 Subject: [PATCH] Add Plesk hosting integration for domain mapping Adds a new Plesk provider that uses the REST API v2 CLI gateway to manage site aliases and subdomains automatically when domains are mapped or removed. Supports API key and Basic Auth, www alias handling, and AutoSSL. Co-Authored-By: Claude Opus 4.6 --- assets/img/hosts/plesk.svg | 18 ++ .../class-integration-registry.php | 2 + .../plesk/class-plesk-domain-mapping.php | 272 ++++++++++++++++++ .../plesk/class-plesk-integration.php | 241 ++++++++++++++++ 4 files changed, 533 insertions(+) create mode 100644 assets/img/hosts/plesk.svg create mode 100644 inc/integrations/providers/plesk/class-plesk-domain-mapping.php create mode 100644 inc/integrations/providers/plesk/class-plesk-integration.php diff --git a/assets/img/hosts/plesk.svg b/assets/img/hosts/plesk.svg new file mode 100644 index 00000000..89f0c158 --- /dev/null +++ b/assets/img/hosts/plesk.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + Plesk + diff --git a/inc/integrations/class-integration-registry.php b/inc/integrations/class-integration-registry.php index 84cba450..50f7aff5 100644 --- a/inc/integrations/class-integration-registry.php +++ b/inc/integrations/class-integration-registry.php @@ -128,6 +128,7 @@ 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()); @@ -173,6 +174,7 @@ 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()); diff --git a/inc/integrations/providers/plesk/class-plesk-domain-mapping.php b/inc/integrations/providers/plesk/class-plesk-domain-mapping.php new file mode 100644 index 00000000..8a2a37c2 --- /dev/null +++ b/inc/integrations/providers/plesk/class-plesk-domain-mapping.php @@ -0,0 +1,272 @@ + [ + 'send_domains' => __('Add domain aliases in Plesk whenever a new domain mapping gets created on your network', 'ultimate-multisite'), + 'autossl' => __('SSL certificates will be automatically provisioned if Plesk SSL It! or Let\'s Encrypt extension is active', 'ultimate-multisite'), + ], + 'will_not' => [], + ]; + + if (is_subdomain_install()) { + $explainer_lines['will']['send_sub_domains'] = __('Add subdomains in Plesk whenever a new site gets created on your network', 'ultimate-multisite'); + } + + return $explainer_lines; + } + + /** + * {@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); + } + + /** + * Gets the parent Plesk_Integration for API calls. + * + * @since 2.5.0 + * @return Plesk_Integration + */ + private function get_plesk(): Plesk_Integration { + + /** @var Plesk_Integration */ + return $this->get_integration(); + } + + /** + * Called when a new domain is mapped. + * + * Creates a site alias in Plesk via the CLI gateway. + * + * @since 2.5.0 + * + * @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 { + + $base_domain = $this->get_plesk()->get_credential('WU_PLESK_DOMAIN'); + + if (empty($base_domain)) { + wu_log_add('integration-plesk', __('Missing WU_PLESK_DOMAIN; cannot add alias.', 'ultimate-multisite'), LogLevel::ERROR); + + return; + } + + // Create site alias + $this->log_response( + sprintf('Add alias %s', $domain), + $this->get_plesk()->send_plesk_api_request( + '/api/v2/cli/site_alias/call', + 'POST', + [ + 'params' => ['--create', $domain, '-domain', $base_domain], + ] + ) + ); + + // Optionally add www alias + if (! str_starts_with($domain, 'www.') && \WP_Ultimo\Managers\Domain_Manager::get_instance()->should_create_www_subdomain($domain)) { + $www = 'www.' . $domain; + + $this->log_response( + sprintf('Add alias %s', $www), + $this->get_plesk()->send_plesk_api_request( + '/api/v2/cli/site_alias/call', + 'POST', + [ + 'params' => ['--create', $www, '-domain', $base_domain], + ] + ) + ); + } + } + + /** + * Called when a mapped domain is removed. + * + * Deletes the site alias from Plesk via the CLI gateway. + * + * @since 2.5.0 + * + * @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 { + + // Delete site alias + $this->log_response( + sprintf('Delete alias %s', $domain), + $this->get_plesk()->send_plesk_api_request( + '/api/v2/cli/site_alias/call', + 'POST', + [ + 'params' => ['--delete', $domain], + ] + ) + ); + + // Also try to remove www alias + if (! str_starts_with($domain, 'www.')) { + $www = 'www.' . $domain; + + $this->log_response( + sprintf('Delete alias %s', $www), + $this->get_plesk()->send_plesk_api_request( + '/api/v2/cli/site_alias/call', + 'POST', + [ + 'params' => ['--delete', $www], + ] + ) + ); + } + } + + /** + * Called when a new subdomain is added. + * + * Creates a subdomain in Plesk via the CLI gateway. + * + * @since 2.5.0 + * + * @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 { + + $base_domain = $this->get_plesk()->get_credential('WU_PLESK_DOMAIN'); + + if (empty($base_domain)) { + wu_log_add('integration-plesk', __('Missing WU_PLESK_DOMAIN; cannot add subdomain.', 'ultimate-multisite'), LogLevel::ERROR); + + return; + } + + $this->log_response( + sprintf('Add subdomain %s', $subdomain), + $this->get_plesk()->send_plesk_api_request( + '/api/v2/cli/subdomain/call', + 'POST', + [ + 'params' => ['--create', $subdomain, '-domain', $base_domain, '-www-root', '/httpdocs'], + ] + ) + ); + } + + /** + * Called when a subdomain is removed. + * + * Deletes the subdomain from Plesk via the CLI gateway. + * + * @since 2.5.0 + * + * @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 { + + $this->log_response( + sprintf('Delete subdomain %s', $subdomain), + $this->get_plesk()->send_plesk_api_request( + '/api/v2/cli/subdomain/call', + 'POST', + [ + 'params' => ['--delete', $subdomain], + ] + ) + ); + } + + /** + * Log an API response with a contextual label. + * + * @since 2.5.0 + * + * @param string $action_label Descriptive label for the action. + * @param array|\WP_Error $response The API response. + * @return void + */ + protected function log_response(string $action_label, $response): void { + + if (is_wp_error($response)) { + wu_log_add('integration-plesk', sprintf('[%s] %s', $action_label, $response->get_error_message()), LogLevel::ERROR); + + return; + } + + wu_log_add('integration-plesk', sprintf('[%s] %s', $action_label, wp_json_encode($response))); + } + + /** + * {@inheritdoc} + */ + public function test_connection() { + + return $this->get_plesk()->test_connection(); + } +} diff --git a/inc/integrations/providers/plesk/class-plesk-integration.php b/inc/integrations/providers/plesk/class-plesk-integration.php new file mode 100644 index 00000000..16d802c6 --- /dev/null +++ b/inc/integrations/providers/plesk/class-plesk-integration.php @@ -0,0 +1,241 @@ +set_description(__('Integrates with Plesk to add and remove domain aliases automatically when domains are mapped or removed.', 'ultimate-multisite')); + $this->set_logo(function_exists('wu_get_asset') ? wu_get_asset('plesk.svg', 'img/hosts') : ''); + $this->set_tutorial_link('https://ultimatemultisite.com/docs/user-guide/host-integrations/plesk'); + $this->set_constants( + [ + 'WU_PLESK_HOST', + ['WU_PLESK_API_KEY', 'WU_PLESK_PASSWORD'], + 'WU_PLESK_DOMAIN', + ] + ); + $this->set_optional_constants(['WU_PLESK_PORT', 'WU_PLESK_USERNAME']); + $this->set_supports(['autossl', 'no-instructions']); + } + + /** + * {@inheritdoc} + */ + public function detect(): bool { + + return false; + } + + /** + * {@inheritdoc} + */ + public function test_connection() { + + $response = $this->send_plesk_api_request('/api/v2/server', 'GET'); + + if (is_wp_error($response)) { + return $response; + } + + if (isset($response['platform'])) { + return true; + } + + return new \WP_Error( + 'connection-failed', + sprintf( + /* translators: %s is the error message from the API */ + __('Failed to connect to Plesk API: %s', 'ultimate-multisite'), + $response['error'] ?? __('Unknown error', 'ultimate-multisite') + ) + ); + } + + /** + * {@inheritdoc} + */ + public function get_fields(): array { + + return [ + 'WU_PLESK_HOST' => [ + 'title' => __('Plesk Host', 'ultimate-multisite'), + 'desc' => __('The hostname or IP address of your Plesk server (e.g., server.example.com). Do not include the port or protocol.', 'ultimate-multisite'), + 'placeholder' => __('e.g. server.example.com', 'ultimate-multisite'), + ], + 'WU_PLESK_PORT' => [ + 'title' => __('Plesk Port', 'ultimate-multisite'), + 'desc' => __('The port Plesk listens on. Defaults to 8443 if not set.', 'ultimate-multisite'), + 'placeholder' => __('8443', 'ultimate-multisite'), + 'value' => '8443', + ], + 'WU_PLESK_API_KEY' => [ + 'type' => 'password', + 'html_attr' => ['autocomplete' => 'new-password'], + 'title' => __('Plesk API Key', 'ultimate-multisite'), + 'desc' => __('Generate an API key in Plesk under Tools & Settings → API. Optional if using username/password authentication.', 'ultimate-multisite'), + 'placeholder' => __('Your API key', 'ultimate-multisite'), + ], + 'WU_PLESK_USERNAME' => [ + 'title' => __('Plesk Username', 'ultimate-multisite'), + 'desc' => __('Plesk admin username. Only required if authenticating with a password instead of an API key.', 'ultimate-multisite'), + 'placeholder' => __('e.g. admin', 'ultimate-multisite'), + ], + 'WU_PLESK_PASSWORD' => [ + 'type' => 'password', + 'html_attr' => ['autocomplete' => 'new-password'], + 'title' => __('Plesk Password', 'ultimate-multisite'), + 'desc' => __('Plesk admin password. Optional if using API key authentication.', 'ultimate-multisite'), + 'placeholder' => __('Your password', 'ultimate-multisite'), + ], + 'WU_PLESK_DOMAIN' => [ + 'title' => __('Base Domain', 'ultimate-multisite'), + 'desc' => __('The domain in Plesk that your WordPress multisite is served from. Aliases will be attached to this domain.', 'ultimate-multisite'), + 'placeholder' => __('e.g. network.example.com', 'ultimate-multisite'), + ], + ]; + } + + /** + * Sends a request to the Plesk REST API v2. + * + * Supports API key authentication (preferred) or HTTP Basic Auth as a fallback. + * + * @since 2.5.0 + * + * @param string $endpoint API endpoint (e.g. /api/v2/server). + * @param string $method HTTP method (GET, POST, DELETE, etc.). + * @param array|string $data Request body data (for POST/PUT/PATCH). + * @return array|\WP_Error + */ + public function send_plesk_api_request(string $endpoint, string $method = 'GET', $data = []) { + + $host = $this->get_credential('WU_PLESK_HOST'); + + if (empty($host)) { + wu_log_add('integration-plesk', 'WU_PLESK_HOST not defined or empty'); + + return new \WP_Error('wu_plesk_no_host', __('Missing WU_PLESK_HOST', 'ultimate-multisite')); + } + + $port = $this->get_credential('WU_PLESK_PORT') ?: '8443'; + $api_url = sprintf('https://%s:%s%s', $host, $port, $endpoint); + + $headers = [ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + 'User-Agent' => 'WP-Ultimo-Plesk-Integration/2.0', + ]; + + // Auth: prefer API key, fall back to Basic Auth + $api_key = $this->get_credential('WU_PLESK_API_KEY'); + $username = $this->get_credential('WU_PLESK_USERNAME'); + $password = $this->get_credential('WU_PLESK_PASSWORD'); + + if (! empty($api_key)) { + $headers['X-API-Key'] = $api_key; + } elseif (! empty($username) && ! empty($password)) { + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + $headers['Authorization'] = 'Basic ' . base64_encode($username . ':' . $password); + } else { + wu_log_add('integration-plesk', 'No authentication credentials configured (need API key or username/password)'); + + return new \WP_Error('wu_plesk_no_auth', __('Missing Plesk authentication credentials', 'ultimate-multisite')); + } + + $args = [ + 'method' => $method, + 'timeout' => 45, + 'headers' => $headers, + ]; + + if (in_array($method, ['POST', 'PUT', 'PATCH'], true) && ! empty($data)) { + $args['body'] = wp_json_encode($data); + } + + wu_log_add('integration-plesk', sprintf('Making %s request to: %s', $method, $api_url)); + + if (! empty($data)) { + wu_log_add('integration-plesk', sprintf('Request data: %s', wp_json_encode($data))); + } + + $response = wp_remote_request($api_url, $args); + + if (is_wp_error($response)) { + wu_log_add('integration-plesk', sprintf('API request failed: %s', $response->get_error_message())); + + return $response; + } + + $response_code = wp_remote_retrieve_response_code($response); + $response_body = wp_remote_retrieve_body($response); + + wu_log_add('integration-plesk', sprintf('API response code: %d, body: %s', $response_code, $response_body)); + + if ($response_code >= 200 && $response_code < 300) { + if (empty($response_body)) { + return ['success' => true]; + } + + $body = json_decode($response_body, true); + + if (json_last_error() === JSON_ERROR_NONE) { + return $body; + } + + // CLI gateway may return plain text on success + return [ + 'success' => true, + 'output' => $response_body, + ]; + } + + $error_data = [ + 'success' => false, + 'error' => sprintf('HTTP %d error', $response_code), + 'response_code' => $response_code, + 'response_body' => $response_body, + ]; + + if (! empty($response_body)) { + $error_body = json_decode($response_body, true); + + if (json_last_error() === JSON_ERROR_NONE && isset($error_body['message'])) { + $error_data['error'] = $error_body['message']; + } + } + + return new \WP_Error( + 'wu_plesk_api_error', + $error_data['error'], + $error_data + ); + } +}