From fb6112f174fc6272e6d1aade38557e4a2bc6d9d0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 12:37:53 +0000 Subject: [PATCH] refactor: extract DesecManager from DesecController Agent-Logs-Url: https://github.com/nextcloud/all-in-one/sessions/7109ac88-02d1-44bf-8602-5f15873a67e5 Co-authored-by: szaimen <42591237+szaimen@users.noreply.github.com> --- php/src/Controller/DesecController.php | 154 +--------------------- php/src/Controller/DockerController.php | 5 +- php/src/Cron/UpdateDesecIp.php | 6 +- php/src/DependencyInjection.php | 6 + php/src/Desec/DesecManager.php | 166 ++++++++++++++++++++++++ 5 files changed, 185 insertions(+), 152 deletions(-) create mode 100644 php/src/Desec/DesecManager.php diff --git a/php/src/Controller/DesecController.php b/php/src/Controller/DesecController.php index eb1e8dc2..9855911b 100644 --- a/php/src/Controller/DesecController.php +++ b/php/src/Controller/DesecController.php @@ -4,27 +4,17 @@ declare(strict_types=1); namespace AIO\Controller; use AIO\Data\ConfigurationManager; -use GuzzleHttp\Client; -use GuzzleHttp\Exception\TransferException; +use AIO\Desec\DesecManager; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; readonly class DesecController { - private const string DESEC_API_BASE = 'https://desec.io/api/v1'; - private const int MAX_SLUG_ATTEMPTS = 5; - private const int SLUG_BYTES = 5; // bin2hex → 10-char slug private const string SLUG_PATTERN = '/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/'; - private Client $guzzleClient; - public function __construct( private ConfigurationManager $configurationManager, + private DesecManager $desecManager, ) { - $this->guzzleClient = new Client([ - 'timeout' => 15, - 'connect_timeout' => 10, - 'http_errors' => false, - ]); } public function Register(Request $request, Response $response, array $args): Response { @@ -43,14 +33,14 @@ readonly class DesecController { // 24 random bytes → 48-char hex password; satisfies deSEC's minimum length // and lets the user log in at desec.io if they ever need to. $password = bin2hex(random_bytes(24)); - $token = $this->registerDesecAccount($email, $password); - $this->saveAccountCredentials($token, $password, $email); + $token = $this->desecManager->registerAccount($email, $password); + $this->desecManager->saveAccountCredentials($token, $password, $email); } - $domain = $this->registerDesecDomain($token, $slug); - $this->enableDesecContainers(); + $domain = $this->desecManager->registerDomain($token, $slug); + $this->desecManager->enableDesecContainers(); $this->configurationManager->setDomain($domain, true); - $this->updateIpIfDesecDomain(); + $this->desecManager->updateIpIfDesecDomain(); return $response->withStatus(201)->withHeader('Location', '.'); } catch (\Exception $ex) { @@ -95,134 +85,4 @@ readonly class DesecController { } return $slug; } - - private function saveAccountCredentials(string $token, string $password, string $email): void { - $this->configurationManager->startTransaction(); - $this->configurationManager->desecToken = $token; - $this->configurationManager->desecPassword = $password; - $this->configurationManager->desecEmail = $email; - $this->configurationManager->commitTransaction(); - } - - private function enableDesecContainers(): void { - $this->configurationManager->startTransaction(); - $enabled = array_values(array_filter( - $this->configurationManager->aioCommunityContainers, - fn(string $cc): bool => $cc !== '', - )); - if (!in_array('caddy', $enabled, true)) { - $enabled[] = 'caddy'; - } - if (!in_array('dnsmasq', $enabled, true)) { - $enabled[] = 'dnsmasq'; - } - $this->configurationManager->aioCommunityContainers = $enabled; - $this->configurationManager->commitTransaction(); - } - - public function updateIpIfDesecDomain(): void { - if (!$this->configurationManager->isDesecDomain()) { - return; - } - - $domain = $this->configurationManager->domain; - $token = $this->configurationManager->desecToken; - - try { - $res = $this->guzzleClient->get('https://update.dedyn.io/', [ - 'query' => ['hostname' => $domain], - 'headers' => ['Authorization' => 'Token ' . $token], - ]); - $status = trim($res->getBody()->getContents()); - if (str_starts_with($status, 'good') || str_starts_with($status, 'nochg')) { - error_log('deSEC IP update for ' . $domain . ': ' . $status); - } else { - error_log('deSEC IP update for ' . $domain . ' returned unexpected response: ' . $status); - } - } catch (\Exception $e) { - error_log('Could not update deSEC DNS record for ' . $domain . ': ' . $e->getMessage()); - } - } - - /** - * Creates a new deSEC account and returns the API token issued for it. - * - * @throws \Exception on network failure or an unexpected HTTP response - */ - private function registerDesecAccount(string $email, string $password): string { - try { - $res = $this->guzzleClient->post(self::DESEC_API_BASE . '/auth/', [ - 'json' => ['email' => $email, 'password' => $password], - ]); - } catch (TransferException $e) { - throw new \Exception('Could not reach the deSEC API: ' . $e->getMessage()); - } - - $code = $res->getStatusCode(); - $body = $res->getBody()->getContents(); - - if ($code === 400) { - $data = json_decode($body, true, 512, JSON_THROW_ON_ERROR); - if (is_array($data) && isset($data['email'])) { - throw new \Exception( - 'This email address is already registered at deSEC. ' - . 'Please log in at https://desec.io to retrieve your token and set up your domain manually.', - ); - } - throw new \Exception('Registration at deSEC failed (HTTP 400): ' . $body); - } - - if ($code !== 201) { - throw new \Exception('Unexpected response from deSEC during account registration (HTTP ' . $code . '): ' . $body); - } - - $data = json_decode($body, true, 512, JSON_THROW_ON_ERROR); - if (!is_array($data) || !isset($data['token']['token']) || !is_string($data['token']['token'])) { - throw new \Exception('Could not extract the API token from the deSEC response. Please try again.'); - } - - return $data['token']['token']; - } - - /** - * Registers a dedyn.io domain for the authenticated account. - * When $slug is empty a random 10-character slug is tried up to MAX_SLUG_ATTEMPTS times. - * - * @return string the fully-qualified domain name that was registered - * @throws \Exception if the slug is taken, on network failure, or after exhausting random attempts - */ - private function registerDesecDomain(string $token, string $slug): string { - $random = $slug === ''; - $attempts = $random ? self::MAX_SLUG_ATTEMPTS : 1; - - for ($i = 0; $i < $attempts; $i++) { - $domain = ($random ? bin2hex(random_bytes(self::SLUG_BYTES)) : $slug) . ConfigurationManager::DEDYN_SUFFIX; - - try { - $res = $this->guzzleClient->post(self::DESEC_API_BASE . '/domains/', [ - 'headers' => ['Authorization' => 'Token ' . $token], - 'json' => ['name' => $domain], - ]); - } catch (TransferException $e) { - throw new \Exception('Could not reach the deSEC API: ' . $e->getMessage()); - } - - $code = $res->getStatusCode(); - - if ($code === 201) { - return $domain; - } - - if ($code === 409) { - if (!$random) { - throw new \Exception('"' . $domain . '" is already taken. Please choose a different subdomain and try again.'); - } - continue; - } - - throw new \Exception('Unexpected response from deSEC during domain registration (HTTP ' . $code . '): ' . $res->getBody()->getContents()); - } - - throw new \Exception('Could not register a free dedyn.io domain after ' . self::MAX_SLUG_ATTEMPTS . ' attempts. Please try again.'); - } } diff --git a/php/src/Controller/DockerController.php b/php/src/Controller/DockerController.php index 6e103058..06176f51 100644 --- a/php/src/Controller/DockerController.php +++ b/php/src/Controller/DockerController.php @@ -6,6 +6,7 @@ namespace AIO\Controller; use AIO\Container\Container; use AIO\Container\ContainerState; use AIO\ContainerDefinitionFetcher; +use AIO\Desec\DesecManager; use AIO\Docker\DockerActionManager; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; @@ -19,7 +20,7 @@ readonly class DockerController { private DockerActionManager $dockerActionManager, private ContainerDefinitionFetcher $containerDefinitionFetcher, private ConfigurationManager $configurationManager, - private DesecController $desecController, + private DesecManager $desecManager, ) { } @@ -265,7 +266,7 @@ readonly class DockerController { $this->StopDomaincheckContainer(); // Refresh the deSEC DNS record with the current public IP before starting containers - $this->desecController->updateIpIfDesecDomain(); + $this->desecManager->updateIpIfDesecDomain(); $id = self::TOP_CONTAINER; diff --git a/php/src/Cron/UpdateDesecIp.php b/php/src/Cron/UpdateDesecIp.php index 55bbe91f..71f1ea04 100644 --- a/php/src/Cron/UpdateDesecIp.php +++ b/php/src/Cron/UpdateDesecIp.php @@ -11,7 +11,7 @@ require __DIR__ . '/../../vendor/autoload.php'; $container = \AIO\DependencyInjection::GetContainer(); -/** @var \AIO\Controller\DesecController $desecController */ -$desecController = $container->get(\AIO\Controller\DesecController::class); +/** @var \AIO\Desec\DesecManager $desecManager */ +$desecManager = $container->get(\AIO\Desec\DesecManager::class); -$desecController->updateIpIfDesecDomain(); +$desecManager->updateIpIfDesecDomain(); diff --git a/php/src/DependencyInjection.php b/php/src/DependencyInjection.php index a7035a96..f6a8a2df 100644 --- a/php/src/DependencyInjection.php +++ b/php/src/DependencyInjection.php @@ -35,6 +35,12 @@ class DependencyInjection $container->get(GitHubContainerRegistryManager::class) ) ); + $container->set( + \AIO\Desec\DesecManager::class, + new \AIO\Desec\DesecManager( + $container->get(\AIO\Data\ConfigurationManager::class), + ) + ); $container->set( \AIO\Auth\PasswordGenerator::class, new \AIO\Auth\PasswordGenerator() diff --git a/php/src/Desec/DesecManager.php b/php/src/Desec/DesecManager.php new file mode 100644 index 00000000..4d3d3764 --- /dev/null +++ b/php/src/Desec/DesecManager.php @@ -0,0 +1,166 @@ +guzzleClient = new Client([ + 'timeout' => 15, + 'connect_timeout' => 10, + 'http_errors' => false, + ]); + } + + /** + * Creates a new deSEC account and returns the API token issued for it. + * + * @throws \Exception on network failure or an unexpected HTTP response + */ + public function registerAccount(string $email, string $password): string { + try { + $res = $this->guzzleClient->post(self::DESEC_API_BASE . '/auth/', [ + 'json' => ['email' => $email, 'password' => $password], + ]); + } catch (TransferException $e) { + throw new \Exception('Could not reach the deSEC API: ' . $e->getMessage()); + } + + $code = $res->getStatusCode(); + $body = $res->getBody()->getContents(); + + if ($code === 400) { + $data = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + if (is_array($data) && isset($data['email'])) { + throw new \Exception( + 'This email address is already registered at deSEC. ' + . 'Please log in at https://desec.io to retrieve your token and set up your domain manually.', + ); + } + throw new \Exception('Registration at deSEC failed (HTTP 400): ' . $body); + } + + if ($code !== 201) { + throw new \Exception('Unexpected response from deSEC during account registration (HTTP ' . $code . '): ' . $body); + } + + $data = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($data) || !isset($data['token']['token']) || !is_string($data['token']['token'])) { + throw new \Exception('Could not extract the API token from the deSEC response. Please try again.'); + } + + return $data['token']['token']; + } + + /** + * Registers a dedyn.io domain for the authenticated account. + * When $slug is empty a random 10-character slug is tried up to MAX_SLUG_ATTEMPTS times. + * + * @return string the fully-qualified domain name that was registered + * @throws \Exception if the slug is taken, on network failure, or after exhausting random attempts + */ + public function registerDomain(string $token, string $slug): string { + $random = $slug === ''; + $attempts = $random ? self::MAX_SLUG_ATTEMPTS : 1; + + for ($i = 0; $i < $attempts; $i++) { + $domain = ($random ? bin2hex(random_bytes(self::SLUG_BYTES)) : $slug) . ConfigurationManager::DEDYN_SUFFIX; + + try { + $res = $this->guzzleClient->post(self::DESEC_API_BASE . '/domains/', [ + 'headers' => ['Authorization' => 'Token ' . $token], + 'json' => ['name' => $domain], + ]); + } catch (TransferException $e) { + throw new \Exception('Could not reach the deSEC API: ' . $e->getMessage()); + } + + $code = $res->getStatusCode(); + + if ($code === 201) { + return $domain; + } + + if ($code === 409) { + if (!$random) { + throw new \Exception('"' . $domain . '" is already taken. Please choose a different subdomain and try again.'); + } + continue; + } + + throw new \Exception('Unexpected response from deSEC during domain registration (HTTP ' . $code . '): ' . $res->getBody()->getContents()); + } + + throw new \Exception('Could not register a free dedyn.io domain after ' . self::MAX_SLUG_ATTEMPTS . ' attempts. Please try again.'); + } + + /** + * Persists deSEC account credentials to the AIO configuration atomically. + */ + public function saveAccountCredentials(string $token, string $password, string $email): void { + $this->configurationManager->startTransaction(); + $this->configurationManager->desecToken = $token; + $this->configurationManager->desecPassword = $password; + $this->configurationManager->desecEmail = $email; + $this->configurationManager->commitTransaction(); + } + + /** + * Ensures the caddy and dnsmasq community containers are enabled. + */ + public function enableDesecContainers(): void { + $this->configurationManager->startTransaction(); + $enabled = array_values(array_filter( + $this->configurationManager->aioCommunityContainers, + fn(string $cc): bool => $cc !== '', + )); + if (!in_array('caddy', $enabled, true)) { + $enabled[] = 'caddy'; + } + if (!in_array('dnsmasq', $enabled, true)) { + $enabled[] = 'dnsmasq'; + } + $this->configurationManager->aioCommunityContainers = $enabled; + $this->configurationManager->commitTransaction(); + } + + /** + * Updates the deSEC dynamic-DNS record with the current public IP. + * Does nothing when the configured domain is not a deSEC-managed dedyn.io domain. + */ + public function updateIpIfDesecDomain(): void { + if (!$this->configurationManager->isDesecDomain()) { + return; + } + + $domain = $this->configurationManager->domain; + $token = $this->configurationManager->desecToken; + + try { + $res = $this->guzzleClient->get('https://update.dedyn.io/', [ + 'query' => ['hostname' => $domain], + 'headers' => ['Authorization' => 'Token ' . $token], + ]); + $status = trim($res->getBody()->getContents()); + if (str_starts_with($status, 'good') || str_starts_with($status, 'nochg')) { + error_log('deSEC IP update for ' . $domain . ': ' . $status); + } else { + error_log('deSEC IP update for ' . $domain . ' returned unexpected response: ' . $status); + } + } catch (\Exception $e) { + error_log('Could not update deSEC DNS record for ' . $domain . ': ' . $e->getMessage()); + } + } +}