From e0305457fd5855b30b1b6b8ba695ed5c5a03267f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 12:02:21 +0000 Subject: [PATCH] rewrite DesecController; convert desecToken/desecPassword to virtual properties Agent-Logs-Url: https://github.com/nextcloud/all-in-one/sessions/10d22120-1d15-4613-bcb4-a7ca9f3705ce Co-authored-by: szaimen <42591237+szaimen@users.noreply.github.com> --- php/public/index.php | 2 +- php/src/Controller/DesecController.php | 139 +++++-------------------- php/src/Data/ConfigurationManager.php | 64 ++++-------- 3 files changed, 52 insertions(+), 153 deletions(-) diff --git a/php/public/index.php b/php/public/index.php index b6b76a85..9d34eabd 100644 --- a/php/public/index.php +++ b/php/public/index.php @@ -182,7 +182,7 @@ $app->get('/containers', function (Request $request, Response $response, array $ 'community_containers_enabled' => $configurationManager->aioCommunityContainers, 'bypass_container_update' => $bypass_container_update, 'desec_email' => $configurationManager->desecEmail, - 'desec_password' => $configurationManager->getDesecPassword(), + 'desec_password' => $configurationManager->desecPassword, 'is_desec_domain' => $configurationManager->isDesecDomain(), 'desec_account_registered' => $configurationManager->isDesecAccountRegistered(), ]); diff --git a/php/src/Controller/DesecController.php b/php/src/Controller/DesecController.php index e08d0d4a..bec4ddc4 100644 --- a/php/src/Controller/DesecController.php +++ b/php/src/Controller/DesecController.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace AIO\Controller; use AIO\Data\ConfigurationManager; -use AIO\Data\InvalidSettingConfigurationException; use GuzzleHttp\Client; use GuzzleHttp\Exception\TransferException; use Psr\Http\Message\ResponseInterface as Response; @@ -14,7 +13,7 @@ readonly class DesecController { private const string DESEC_API_BASE = 'https://desec.io/api/v1'; private const string DEDYN_SUFFIX = '.dedyn.io'; private const int MAX_SLUG_ATTEMPTS = 5; - private const int SLUG_BYTES = 5; // 10-char hex slug + 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; @@ -30,19 +29,15 @@ readonly class DesecController { } public function Register(Request $request, Response $response, array $args): Response { - // Only allow registration when no domain is configured yet if ($this->configurationManager->domain !== '') { $response->getBody()->write('A domain is already configured. Reset the AIO instance first to register a new domain.'); return $response->withStatus(422); } - // When a deSEC account was already registered (token exists) but domain creation previously - // failed, we skip account registration and re-use the stored token and email. $accountAlreadyRegistered = $this->configurationManager->isDesecAccountRegistered(); if ($accountAlreadyRegistered) { - $token = $this->configurationManager->getDesecToken(); - // email is already stored; no need to validate or update it + $token = $this->configurationManager->desecToken; } else { $email = trim((string)($request->getParsedBody()['desec_email'] ?? '')); if ($email === '' || filter_var($email, FILTER_VALIDATE_EMAIL) === false) { @@ -62,26 +57,19 @@ readonly class DesecController { try { if (!$accountAlreadyRegistered) { - // Register an account at deSEC and obtain an API token. - // The password is stored so the user can log in to desec.io directly if needed. - // 24 random bytes encoded as hex produce a 48-character password. + // 24 random bytes → 48-char hex password, stored so the user can log in at desec.io. $password = bin2hex(random_bytes(24)); $token = $this->registerDesecAccount($email, $password); - // Persist the token, password and email immediately so that a subsequent - // domain-registration failure leaves the account credentials stored and allows - // the user to retry. $this->configurationManager->startTransaction(); - $this->configurationManager->setDesecToken($token); - $this->configurationManager->setDesecPassword($password); - $this->configurationManager->desecEmail = $email; + $this->configurationManager->desecToken = $token; + $this->configurationManager->desecPassword = $password; + $this->configurationManager->desecEmail = $email; $this->configurationManager->commitTransaction(); } - // Register a free dedyn.io subdomain $domain = $this->registerDesecDomain($token, $slug); - // Auto-enable caddy and dnsmasq (idempotent — safe to call even on retry) $this->configurationManager->startTransaction(); $enabled = array_values(array_filter( $this->configurationManager->aioCommunityContainers, @@ -96,36 +84,23 @@ readonly class DesecController { $this->configurationManager->aioCommunityContainers = $enabled; $this->configurationManager->commitTransaction(); - // Set the domain; skip the reachability validation because the domain was just - // created and DNS propagation may not have completed yet. $this->configurationManager->setDomain($domain, true); - - // Perform the first DNS IP update so the record is populated immediately $this->updateIpIfDesecDomain(); return $response->withStatus(201)->withHeader('Location', '.'); - } catch (InvalidSettingConfigurationException $ex) { - $response->getBody()->write($ex->getMessage()); - return $response->withStatus(422); } catch (\Exception $ex) { $response->getBody()->write($ex->getMessage()); return $response->withStatus(422); } } - /** - * Updates the deSEC DNS A/AAAA record with the current public IP of this host. - * Uses deSEC's DynDNS2-compatible update endpoint, which auto-detects the requester's IP. - * Safe to call frequently; the endpoint returns "nochg" when the IP has not changed. - * Errors are logged but never thrown, so callers are not interrupted. - */ public function updateIpIfDesecDomain(): void { if (!$this->configurationManager->isDesecDomain()) { return; } $domain = $this->configurationManager->domain; - $token = $this->configurationManager->getDesecToken(); + $token = $this->configurationManager->desecToken; try { $res = $this->guzzleClient->get('https://update.dedyn.io/', [ @@ -143,11 +118,6 @@ readonly class DesecController { } } - /** - * Creates a new deSEC account and returns the API token from the response. - * - * @throws \Exception on network failure or an unexpected API response - */ private function registerDesecAccount(string $email, string $password): string { try { $res = $this->guzzleClient->post(self::DESEC_API_BASE . '/auth/', [ @@ -157,10 +127,10 @@ readonly class DesecController { throw new \Exception('Could not reach the deSEC API: ' . $e->getMessage()); } - $httpCode = $res->getStatusCode(); + $code = $res->getStatusCode(); $body = $res->getBody()->getContents(); - if ($httpCode === 400) { + if ($code === 400) { $data = json_decode($body, true, 512, JSON_THROW_ON_ERROR); if (is_array($data) && isset($data['email'])) { throw new \Exception( @@ -171,103 +141,50 @@ readonly class DesecController { throw new \Exception('Registration at deSEC failed (HTTP 400): ' . $body); } - if ($httpCode !== 201) { - throw new \Exception( - 'Unexpected response from deSEC during account registration ' - . '(HTTP ' . $httpCode . '): ' . $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.', - ); + throw new \Exception('Could not extract the API token from the deSEC response. Please try again.'); } return $data['token']['token']; } - /** - * Registers a new dedyn.io subdomain and returns its full name. - * - * When $requestedSlug is non-empty the caller's preferred slug is tried once; a 409 - * conflict returns an actionable error immediately (no silent retry). - * When $requestedSlug is empty a random 10-char hex slug is generated and retried up - * to MAX_SLUG_ATTEMPTS times on 409 conflicts. - * - * @throws \Exception when all attempts fail or a network/API error occurs - */ - private function registerDesecDomain(string $token, string $requestedSlug = ''): string { - if ($requestedSlug !== '') { - // User chose a specific slug — try it exactly once. - $domain = $requestedSlug . self::DEDYN_SUFFIX; + 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) . self::DEDYN_SUFFIX; + try { $res = $this->guzzleClient->post(self::DESEC_API_BASE . '/domains/', [ 'headers' => ['Authorization' => 'Token ' . $token], - 'json' => ['name' => $domain], + 'json' => ['name' => $domain], ]); } catch (TransferException $e) { throw new \Exception('Could not reach the deSEC API: ' . $e->getMessage()); } - $httpCode = $res->getStatusCode(); + $code = $res->getStatusCode(); - if ($httpCode === 201) { + if ($code === 201) { return $domain; } - if ($httpCode === 409) { - throw new \Exception( - '"' . $domain . '" is already taken. Please choose a different subdomain and try again.', - ); - } - - $body = $res->getBody()->getContents(); - throw new \Exception( - 'Unexpected response from deSEC during domain registration ' - . '(HTTP ' . $httpCode . '): ' . $body, - ); - } - - // No slug provided — generate random slugs and retry on conflicts. - $lastError = ''; - - for ($attempt = 0; $attempt < self::MAX_SLUG_ATTEMPTS; $attempt++) { - $slug = bin2hex(random_bytes(self::SLUG_BYTES)); - $domain = $slug . self::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()); - } - - $httpCode = $res->getStatusCode(); - - if ($httpCode === 201) { - return $domain; - } - - if ($httpCode === 409) { - // Slug already taken — try another one - $lastError = '"' . $domain . '" is already taken'; + if ($code === 409) { + if (!$random) { + throw new \Exception('"' . $domain . '" is already taken. Please choose a different subdomain and try again.'); + } continue; } - $body = $res->getBody()->getContents(); - throw new \Exception( - 'Unexpected response from deSEC during domain registration ' - . '(HTTP ' . $httpCode . '): ' . $body, - ); + 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' - . ($lastError !== '' ? ' (' . $lastError . ')' : '') . '. Please try again.', - ); + 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/Data/ConfigurationManager.php b/php/src/Data/ConfigurationManager.php index 2f7ad0b4..d0ae54fe 100644 --- a/php/src/Data/ConfigurationManager.php +++ b/php/src/Data/ConfigurationManager.php @@ -203,54 +203,36 @@ class ConfigurationManager set { $this->set('desec_email', $value); } } - /** - * Stores a deSEC API token in the secrets store. - * Unlike randomly-generated secrets, this token is obtained from the deSEC REST API and - * must be set explicitly; it is never auto-generated. - */ - public function setDesecToken(string $token): void { - $secrets = $this->get('secrets', []); - $secrets['DESEC_TOKEN'] = $token; - $this->set('secrets', $secrets); + public string $desecToken { + get { + $s = $this->get('secrets', []); + return isset($s['DESEC_TOKEN']) && is_string($s['DESEC_TOKEN']) ? $s['DESEC_TOKEN'] : ''; + } + set { + $s = $this->get('secrets', []); + $s['DESEC_TOKEN'] = $value; + $this->set('secrets', $s); + } } - public function getDesecToken(): string { - $secrets = $this->get('secrets', []); - return isset($secrets['DESEC_TOKEN']) && is_string($secrets['DESEC_TOKEN']) - ? $secrets['DESEC_TOKEN'] - : ''; + public string $desecPassword { + get { + $s = $this->get('secrets', []); + return isset($s['DESEC_PASSWORD']) && is_string($s['DESEC_PASSWORD']) ? $s['DESEC_PASSWORD'] : ''; + } + set { + $s = $this->get('secrets', []); + $s['DESEC_PASSWORD'] = $value; + $this->set('secrets', $s); + } } - /** - * Stores the deSEC account password in the secrets store so the user can log in at desec.io. - */ - public function setDesecPassword(string $password): void { - $secrets = $this->get('secrets', []); - $secrets['DESEC_PASSWORD'] = $password; - $this->set('secrets', $secrets); - } - - public function getDesecPassword(): string { - $secrets = $this->get('secrets', []); - return isset($secrets['DESEC_PASSWORD']) && is_string($secrets['DESEC_PASSWORD']) - ? $secrets['DESEC_PASSWORD'] - : ''; - } - - /** - * Returns true when the configured domain is a deSEC dedyn.io subdomain and a token is stored. - */ public function isDesecDomain(): bool { - return str_ends_with($this->domain, '.dedyn.io') && $this->getDesecToken() !== ''; + return str_ends_with($this->domain, '.dedyn.io') && $this->desecToken !== ''; } - /** - * Returns true when a deSEC account token is stored but no domain has been configured yet. - * This happens when account registration succeeded but domain registration subsequently failed. - * In this state the user can retry domain registration with a different slug. - */ public function isDesecAccountRegistered(): bool { - return $this->getDesecToken() !== '' && $this->desecEmail !== '' && $this->domain === ''; + return $this->desecToken !== '' && $this->desecEmail !== '' && $this->domain === ''; } public string $apachePort { @@ -1164,7 +1146,7 @@ class ConfigurationManager 'CADDY_IP_ADDRESS' => in_array('caddy', $this->aioCommunityContainers, true) ? gethostbyname('nextcloud-aio-caddy') : '', 'WHITEBOARD_ENABLED' => $this->isWhiteboardEnabled ? 'yes' : '', 'AIO_VERSION' => $this->getAioVersion(), - 'DESEC_TOKEN' => $this->getDesecToken(), + 'DESEC_TOKEN' => $this->desecToken, default => $this->getRegisteredSecret($placeholder), }; }