diff --git a/inc/integrations/class-integration-registry.php b/inc/integrations/class-integration-registry.php index c5a91038..35cb2744 100644 --- a/inc/integrations/class-integration-registry.php +++ b/inc/integrations/class-integration-registry.php @@ -135,6 +135,7 @@ private function register_core_integrations(): void { $this->register(new Providers\BunnyNet\BunnyNet_Integration()); $this->register(new Providers\LaravelForge\LaravelForge_Integration()); $this->register(new Providers\Amazon_SES\Amazon_SES_Integration()); + $this->register(new Providers\CyberPanel\CyberPanel_Integration()); } /** @@ -184,6 +185,7 @@ private function register_core_capabilities(): void { $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()); + $this->add_capability('cyberpanel', new Providers\CyberPanel\CyberPanel_Domain_Mapping()); } /** diff --git a/inc/integrations/providers/cyberpanel/class-cyberpanel-domain-mapping.php b/inc/integrations/providers/cyberpanel/class-cyberpanel-domain-mapping.php new file mode 100644 index 00000000..d5739c12 --- /dev/null +++ b/inc/integrations/providers/cyberpanel/class-cyberpanel-domain-mapping.php @@ -0,0 +1,296 @@ + wu_add_domain fires + * 2. This module calls CyberPanel createWebsite to add the domain + * 3. Then issues SSL via CyberPanel's acme.sh/Let's Encrypt integration + * 4. User points DNS A record to server IP -> domain works + * + * Flow for subdomains: + * 1. Ultimate Multisite creates a site -> wu_add_subdomain fires + * 2. Wildcard DNS + wildcard SSL handle it automatically -> NOOP + * + * @see https://documenter.getpostman.com/view/2s8Yt1s9Pf + * + * @package WP_Ultimo + * @subpackage Integrations/Providers/CyberPanel + * @since 2.6.0 + */ + +namespace WP_Ultimo\Integrations\Providers\CyberPanel; + +use Psr\Log\LogLevel; +use WP_Ultimo\Integrations\Base_Capability_Module; +use WP_Ultimo\Integrations\Capabilities\Domain_Mapping_Capability; + +// Exit if accessed directly +defined('ABSPATH') || exit; + +/** + * CyberPanel domain mapping capability module. + * + * Uses the shared CyberPanel_Integration for API access. + * + * @since 2.6.0 + */ +class CyberPanel_Domain_Mapping extends Base_Capability_Module implements Domain_Mapping_Capability { + + /** + * Supported features. + * + * @since 2.6.0 + * @var array + */ + protected array $supported_features = ['autossl']; + + /** + * {@inheritdoc} + */ + public function get_capability_id(): string { + + return 'domain-mapping'; + } + + /** + * {@inheritdoc} + */ + public function get_title(): string { + + return __('Domain Mapping', 'ultimate-multisite'); + } + + /** + * {@inheritdoc} + */ + public function get_explainer_lines(): array { + + return [ + 'will' => [ + __('Create a website on CyberPanel when a custom domain is mapped.', 'ultimate-multisite'), + __('Automatically issue a Let\'s Encrypt SSL certificate for the mapped domain.', 'ultimate-multisite'), + ], + 'will_not' => [ + __('Subdomains are handled automatically via wildcard DNS and SSL — no action needed.', '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); + + // Give SSL more time to propagate — Let's Encrypt can take a minute to verify DNS + add_filter('wu_async_process_domain_stage_max_tries', [$this, 'ssl_tries'], 10, 2); + } + + /** + * Gets the parent CyberPanel_Integration for API calls. + * + * @since 2.6.0 + * @return CyberPanel_Integration + */ + private function get_cyberpanel(): CyberPanel_Integration { + + /** @var CyberPanel_Integration */ + return $this->get_integration(); + } + + /** + * Called when a new domain is mapped. + * + * Creates a website on CyberPanel for the mapped domain, then + * explicitly issues an SSL certificate as a safety net. + * + * @since 2.6.0 + * + * @param string $domain The domain name being mapped (e.g. 'example.com'). + * @param int $site_id ID of the site receiving the mapping. + * @return void + */ + public function on_add_domain(string $domain, int $site_id): void { + + $master_domain = $this->get_cyberpanel()->get_master_domain(); + + wu_log_add('integration-cyberpanel', sprintf( + 'Adding domain: %s for site ID: %d (master: %s)', + $domain, + $site_id, + $master_domain + )); + + // Step 1: Create website on CyberPanel for this domain + $result = $this->create_website($domain, $master_domain); + + if (is_wp_error($result)) { + wu_log_add('integration-cyberpanel', 'Failed to create website: ' . $result->get_error_message(), LogLevel::ERROR); + + return; + } + + wu_log_add('integration-cyberpanel', sprintf('Website %s created, requesting SSL...', $domain)); + + // Step 2: Issue SSL certificate explicitly as a safety net + $this->issue_ssl($domain); + } + + /** + * Called when a mapped domain is removed. + * + * Deletes the website from CyberPanel. + * + * @since 2.6.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 { + + wu_log_add('integration-cyberpanel', sprintf('Removing domain: %s for site ID: %d', $domain, $site_id)); + + $this->delete_website($domain); + } + + /** + * Called when a new subdomain is added. + * + * NOOP — wildcard DNS + wildcard SSL cover all subdomains automatically. + * + * @since 2.6.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 { + + // Wildcard handles this automatically + wu_log_add('integration-cyberpanel', sprintf('Subdomain %s — handled by wildcard, no action needed.', $subdomain)); + } + + /** + * Called when a subdomain is removed. + * + * NOOP — wildcard DNS + SSL cover it. + * + * @since 2.6.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 { + + // Wildcard handles this + } + + /** + * Create a website on CyberPanel for the mapped domain. + * + * CyberPanel's standard API does not have a dedicated child-domain + * endpoint, so we use createWebsite to add the domain as a website + * under the same server. The ssl=1 flag triggers automatic Let's + * Encrypt issuance during creation. + * + * @since 2.6.0 + * + * @param string $domain The domain to add. + * @param string $master_domain The main CyberPanel website domain. + * @return array|\WP_Error + */ + private function create_website(string $domain, string $master_domain) { + + $username = $this->get_cyberpanel()->get_credential('WU_CYBERPANEL_USERNAME'); + + return $this->get_cyberpanel()->api_call('createWebsite', [ + 'domainName' => $domain, + 'ownerEmail' => 'ssl@' . $master_domain, + 'packageName' => 'Default', + 'websiteOwner' => $username, + 'ownerPassword' => wp_generate_password(24, false), // Random, not used for login + 'phpSelection' => 'PHP 8.3', + 'ssl' => 1, + ]); + } + + /** + * Delete a website from CyberPanel. + * + * @since 2.6.0 + * + * @param string $domain The domain to remove. + * @return array|\WP_Error + */ + private function delete_website(string $domain) { + + return $this->get_cyberpanel()->api_call('deleteWebsite', [ + 'domainName' => $domain, + ]); + } + + /** + * Issue an SSL certificate for a domain via CyberPanel. + * + * CyberPanel uses acme.sh internally for Let's Encrypt. While + * createWebsite with ssl=1 auto-issues SSL, we also trigger it + * explicitly as a safety net. + * + * @since 2.6.0 + * + * @param string $domain The domain to issue SSL for. + * @return void + */ + private function issue_ssl(string $domain): void { + + $result = $this->get_cyberpanel()->api_call('submitWebsiteStatus', [ + 'websiteName' => $domain, + 'state' => 'issueSSL', + ]); + + if (is_wp_error($result)) { + wu_log_add('integration-cyberpanel', 'SSL issuance failed for ' . $domain . ': ' . $result->get_error_message(), LogLevel::ERROR); + } else { + wu_log_add('integration-cyberpanel', 'SSL issued for ' . $domain); + } + } + + /** + * Increase SSL check retries for CyberPanel. + * + * Let's Encrypt can take a minute to verify DNS, so we allow more + * retries than the default when checking SSL cert status. + * + * @since 2.6.0 + * + * @param int $max_tries Current max tries. + * @param object $domain The domain object. + * @return int + */ + public function ssl_tries($max_tries, $domain) { + + if (method_exists($domain, 'get_stage') && 'checking-ssl-cert' === $domain->get_stage()) { + return 30; // More retries since we control the server + } + + return $max_tries; + } + + /** + * {@inheritdoc} + */ + public function test_connection() { + + return $this->get_cyberpanel()->test_connection(); + } +} diff --git a/inc/integrations/providers/cyberpanel/class-cyberpanel-integration.php b/inc/integrations/providers/cyberpanel/class-cyberpanel-integration.php new file mode 100644 index 00000000..b24b60b8 --- /dev/null +++ b/inc/integrations/providers/cyberpanel/class-cyberpanel-integration.php @@ -0,0 +1,258 @@ +set_logo(function_exists('wu_get_asset') ? wu_get_asset('cyberpanel.svg', 'img/hosts') : ''); + $this->set_tutorial_link('https://ultimatemultisite.com/docs/user-guide/host-integrations/cyberpanel'); + $this->set_constants( + [ + 'WU_CYBERPANEL_HOST', + 'WU_CYBERPANEL_USERNAME', + 'WU_CYBERPANEL_PASSWORD', + ] + ); + $this->set_optional_constants(['WU_CYBERPANEL_PORT', 'WU_CYBERPANEL_MASTER_DOMAIN']); + $this->set_supports(['autossl']); + } + + /** + * {@inheritdoc} + */ + public function get_description(): string { + + return __('Integrates with CyberPanel to automatically create websites for mapped domains and issue SSL certificates via Let\'s Encrypt.', 'ultimate-multisite'); + } + + /** + * {@inheritdoc} + */ + public function detect(): bool { + + return false; + } + + /** + * Tests the connection with the CyberPanel API. + * + * Uses the verifyConn endpoint to confirm credentials are valid. + * + * @since 2.6.0 + * @return true|\WP_Error + */ + public function test_connection() { + + $response = $this->api_call('verifyConn'); + + if (is_wp_error($response)) { + return $response; + } + + if ( ! empty($response['verifyConn'])) { + return true; + } + + return new \WP_Error( + 'cyberpanel-connection-failed', + __('Could not connect to CyberPanel API.', 'ultimate-multisite') + ); + } + + /** + * Returns the list of installation fields for the setup wizard. + * + * @since 2.6.0 + * @return array + */ + public function get_fields(): array { + + return [ + 'WU_CYBERPANEL_HOST' => [ + 'title' => __('CyberPanel Host', 'ultimate-multisite'), + 'desc' => __('The hostname or IP address of your CyberPanel server (e.g., server.example.com or 5.78.200.4). Do not include the port or protocol.', 'ultimate-multisite'), + 'placeholder' => __('e.g. server.example.com', 'ultimate-multisite'), + ], + 'WU_CYBERPANEL_PORT' => [ + 'title' => __('CyberPanel Port', 'ultimate-multisite'), + 'desc' => __('The port CyberPanel listens on. Defaults to 8090 if not set.', 'ultimate-multisite'), + 'placeholder' => __('8090', 'ultimate-multisite'), + 'value' => '8090', + ], + 'WU_CYBERPANEL_USERNAME' => [ + 'title' => __('CyberPanel Username', 'ultimate-multisite'), + 'desc' => __('Your CyberPanel admin username (typically "admin").', 'ultimate-multisite'), + 'placeholder' => __('e.g. admin', 'ultimate-multisite'), + ], + 'WU_CYBERPANEL_PASSWORD' => [ + 'type' => 'password', + 'html_attr' => ['autocomplete' => 'new-password'], + 'title' => __('CyberPanel Password', 'ultimate-multisite'), + 'desc' => __('Your CyberPanel admin password.', 'ultimate-multisite'), + 'placeholder' => __('Your password', 'ultimate-multisite'), + ], + 'WU_CYBERPANEL_MASTER_DOMAIN' => [ + 'title' => __('Master Domain', 'ultimate-multisite'), + 'desc' => __('The primary domain in CyberPanel that your WordPress multisite is served from. If not set, the network\'s current domain will be used.', 'ultimate-multisite'), + 'placeholder' => __('e.g. network.example.com', 'ultimate-multisite'), + ], + ]; + } + + /** + * Returns the master domain configured for the integration. + * + * Falls back to the WordPress multisite DOMAIN_CURRENT_SITE constant + * if no explicit master domain is configured. + * + * @since 2.6.0 + * @return string + */ + public function get_master_domain(): string { + + $domain = $this->get_credential('WU_CYBERPANEL_MASTER_DOMAIN'); + + if (empty($domain)) { + $domain = defined('DOMAIN_CURRENT_SITE') ? DOMAIN_CURRENT_SITE : ''; + } + + return $domain; + } + + /** + * Sends a request to the CyberPanel standard API. + * + * CyberPanel uses per-endpoint URLs at /api/{endpoint}. Authentication + * is passed as adminUser/adminPass fields in the JSON request body. + * + * @since 2.6.0 + * + * @param string $endpoint The API endpoint name (e.g. 'verifyConn', 'createWebsite'). + * @param array $data Additional parameters to merge into the request body. + * @return array|\WP_Error Decoded JSON response or WP_Error on failure. + */ + public function api_call(string $endpoint, array $data = []) { + + $host = $this->get_credential('WU_CYBERPANEL_HOST'); + + if (empty($host)) { + return new \WP_Error('wu_cyberpanel_no_host', __('Missing WU_CYBERPANEL_HOST', 'ultimate-multisite')); + } + + $username = $this->get_credential('WU_CYBERPANEL_USERNAME'); + $password = $this->get_credential('WU_CYBERPANEL_PASSWORD'); + + if (empty($username) || empty($password)) { + return new \WP_Error('wu_cyberpanel_no_auth', __('Missing CyberPanel username or password', 'ultimate-multisite')); + } + + $port = $this->get_credential('WU_CYBERPANEL_PORT') ?: '8090'; + + // Sanitize host: strip protocol, trailing slashes, port if included + $clean_host = preg_replace('#^https?://#', '', (string) $host); + $clean_host = rtrim($clean_host, "; \t\n\r\0\x0B/"); + $clean_host = preg_replace('#:\d+$#', '', $clean_host); + + $api_url = sprintf('https://%s:%s/api/%s', $clean_host, $port, $endpoint); + + // CyberPanel standard API: credentials go in the JSON body + $body = array_merge( + [ + 'adminUser' => $username, + 'adminPass' => $password, + ], + $data + ); + + $response = wp_remote_post( + $api_url, + [ + 'timeout' => 60, + 'sslverify' => false, // CyberPanel commonly uses self-signed certs + 'headers' => [ + 'Content-Type' => 'application/json', + 'User-Agent' => 'Ultimate-Multisite-CyberPanel-Integration/1.0', + ], + 'body' => wp_json_encode($body), + ] + ); + + if (is_wp_error($response)) { + wu_log_add('integration-cyberpanel', sprintf('API error (%s): %s', $endpoint, $response->get_error_message())); + + return $response; + } + + $code = wp_remote_retrieve_response_code($response); + $raw = wp_remote_retrieve_body($response); + + if ($code >= 400) { + wu_log_add('integration-cyberpanel', sprintf('API HTTP %d (%s): %s', $code, $endpoint, $raw)); + + return new \WP_Error( + 'wu_cyberpanel_http_error', + sprintf( + /* translators: %1$d: HTTP status code, %2$s: Response body */ + __('CyberPanel API returned HTTP %1$d: %2$s', 'ultimate-multisite'), + $code, + $raw + ) + ); + } + + $decoded = json_decode($raw, true); + + if (JSON_ERROR_NONE !== json_last_error()) { + wu_log_add('integration-cyberpanel', sprintf('API invalid JSON (%s): %s', $endpoint, substr($raw, 0, 500))); + + return new \WP_Error( + 'wu_cyberpanel_json_error', + sprintf( + /* translators: %s: JSON error message */ + __('Failed to decode CyberPanel API response: %s', 'ultimate-multisite'), + json_last_error_msg() + ) + ); + } + + return $decoded; + } +}