From f7987b26acee3911a39491a5ff5fa736b31578af Mon Sep 17 00:00:00 2001 From: "Simon L." Date: Thu, 18 Jun 2026 13:15:29 +0200 Subject: [PATCH 01/21] feat(desec): add deSEC account + domain registration backend Add the server-side deSEC (dedyn.io) free dynamic-DNS flow so users without a domain can obtain one from the AIO interface. - DesecManager drives the full flow against the deSEC API, matching its real semantics: account creation returns HTTP 202 + email verification (no token), a token is obtained via /auth/login/ only after the email is verified, domain registration handles 201/409, and a wildcard CNAME rrset is created for new accounts. Existing accounts can be used by supplying a password. - The "awaiting verification" step is derived from the stored credentials (email + generated password but no token and no domain yet), not a separate flag. register() returns false for that state so the controller can re-render the awaiting-verification UI instead of surfacing it as an error. - DesecController exposes POST /api/desec/register; DependencyInjection wires the manager; account credentials (email, generated password, token) are stored in the AIO configuration and DESEC_TOKEN is exposed to the caddy container. - The dynamic-DNS record is refreshed with the current public IP on container start (DockerController) and via the cron path (Cron/UpdateDesecIp, cron.sh). - Fix an undefined-variable bug in the desecToken/desecPassword config setters that prevented credentials from being persisted. Co-Authored-By: szaimen <42591237+szaimen@users.noreply.github.com> Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Simon L. --- Containers/mastercontainer/cron.sh | 3 + php/src/Controller/DesecController.php | 32 +++ php/src/Controller/DockerController.php | 7 +- php/src/Cron/UpdateDesecIp.php | 17 ++ php/src/Data/ConfigurationManager.php | 69 +++++ php/src/DependencyInjection.php | 6 + php/src/Desec/DesecManager.php | 361 ++++++++++++++++++++++++ 7 files changed, 494 insertions(+), 1 deletion(-) create mode 100644 php/src/Controller/DesecController.php create mode 100644 php/src/Cron/UpdateDesecIp.php create mode 100644 php/src/Desec/DesecManager.php diff --git a/Containers/mastercontainer/cron.sh b/Containers/mastercontainer/cron.sh index af0a25bc..98ab9c54 100644 --- a/Containers/mastercontainer/cron.sh +++ b/Containers/mastercontainer/cron.sh @@ -57,6 +57,9 @@ while true; do # Check if AIO is outdated sudo -E -u www-data php /var/www/docker-aio/php/src/Cron/OutdatedNotification.php + # Update deSEC DNS IP record (no-op when IP is unchanged or deSEC is not configured) + sudo -E -u www-data php /var/www/docker-aio/php/src/Cron/UpdateDesecIp.php + # Remove sessions older than 24h find "/mnt/docker-aio-config/session/" -mindepth 1 -mmin +1440 -delete diff --git a/php/src/Controller/DesecController.php b/php/src/Controller/DesecController.php new file mode 100644 index 00000000..192e5a68 --- /dev/null +++ b/php/src/Controller/DesecController.php @@ -0,0 +1,32 @@ +getParsedBody()['desec_email'] ?? ''); + $slug = (string)($request->getParsedBody()['desec_slug'] ?? ''); + $password = (string)($request->getParsedBody()['desec_password'] ?? ''); + // register() returns false when a new account was created and we are now awaiting + // the user's email verification. That is a normal state transition, not an error: + // reload the page (201 + Location) so the awaiting-verification UI renders and + // explains the next step, exactly like the fully-registered success path. + $this->desecManager->register($email, $slug, $password); + return $response->withStatus(201)->withHeader('Location', '.'); + } catch (\Exception $ex) { + $response->getBody()->write($ex->getMessage()); + return $response->withStatus(422); + } + } +} diff --git a/php/src/Controller/DockerController.php b/php/src/Controller/DockerController.php index 089256b9..86f7c78c 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,8 @@ readonly class DockerController { public function __construct( private DockerActionManager $dockerActionManager, private ContainerDefinitionFetcher $containerDefinitionFetcher, - private ConfigurationManager $configurationManager + private ConfigurationManager $configurationManager, + private DesecManager $desecManager, ) { } @@ -264,6 +266,9 @@ readonly class DockerController { // Stop domaincheck since apache would not be able to start otherwise $this->StopDomaincheckContainer(); + // Refresh the deSEC DNS record with the current public IP before starting containers + $this->desecManager->updateIpIfDesecDomain(); + $id = self::TOP_CONTAINER; $this->PerformRecursiveContainerStart($id, $pullImage, $addToStreamingResponseBody); diff --git a/php/src/Cron/UpdateDesecIp.php b/php/src/Cron/UpdateDesecIp.php new file mode 100644 index 00000000..71f1ea04 --- /dev/null +++ b/php/src/Cron/UpdateDesecIp.php @@ -0,0 +1,17 @@ +get(\AIO\Desec\DesecManager::class); + +$desecManager->updateIpIfDesecDomain(); diff --git a/php/src/Data/ConfigurationManager.php b/php/src/Data/ConfigurationManager.php index a75a4c16..54a77055 100644 --- a/php/src/Data/ConfigurationManager.php +++ b/php/src/Data/ConfigurationManager.php @@ -11,6 +11,8 @@ use GuzzleHttp\Exception\TransferException; class ConfigurationManager { + public const string DEDYN_SUFFIX = '.dedyn.io'; + private array $secrets = []; private array $config = []; @@ -206,6 +208,72 @@ class ConfigurationManager set { $this->set('turn_domain', $value); } } + public string $desecEmail { + get => $this->get('desec_email', ''); + set { $this->set('desec_email', $value); } + } + + /** + * Base URL of the deSEC API. Configurable via the 'desec_api_base' config key + * (configuration.json) only — intentionally NOT an environment variable — so the + * endpoint can be pointed at a mock during automated tests without any risk of a + * stray env var redirecting it in production. + */ + public string $desecApiBase { + get => $this->get('desec_api_base', 'https://desec.io/api/v1'); + } + + /** + * Base URL of the deSEC dynamic-DNS update endpoint. Configurable via the + * 'desec_update_url' config key (configuration.json) only — see desecApiBase. + */ + public string $desecUpdateUrl { + get => $this->get('desec_update_url', 'https://update.dedyn.io/'); + } + + public string $desecToken { + get { + $secrets = $this->get('secrets', []); + return isset($secrets['DESEC_TOKEN']) && is_string($secrets['DESEC_TOKEN']) ? $secrets['DESEC_TOKEN'] : ''; + } + set { + $secrets = $this->get('secrets', []); + $secrets['DESEC_TOKEN'] = $value; + $this->set('secrets', $secrets); + } + } + + public string $desecPassword { + get { + $secrets = $this->get('secrets', []); + return isset($secrets['DESEC_PASSWORD']) && is_string($secrets['DESEC_PASSWORD']) ? $secrets['DESEC_PASSWORD'] : ''; + } + set { + $secrets = $this->get('secrets', []); + $secrets['DESEC_PASSWORD'] = $value; + $this->set('secrets', $secrets); + } + } + + public function isDesecDomain(): bool { + return str_ends_with($this->domain, self::DEDYN_SUFFIX) && $this->desecToken !== ''; + } + + public function isDesecAccountRegistered(): bool { + return $this->desecToken !== '' && $this->desecEmail !== '' && $this->domain === ''; + } + + /** + * True when a new deSEC account was created (email + generated password stored) but + * its email has not been verified yet, so no API token could be obtained and no + * domain is set. Derived from the stored credentials rather than a separate flag: + * once verification succeeds a token is stored and isDesecAccountRegistered() takes + * over; once a domain is set the deSEC setup is complete. + */ + public function isDesecAwaitingVerification(): bool { + return $this->desecToken === '' && $this->desecEmail !== '' && $this->desecPassword !== '' && $this->domain === ''; + } + public string $apachePort { get => $this->getEnvironmentalVariableOrConfig('APACHE_PORT', 'apache_port', '443'); set { $this->set('apache_port', $value); } @@ -1119,6 +1187,7 @@ class ConfigurationManager 'CADDY_IP_ADDRESS' => in_array('caddy', $this->aioCommunityContainers, true) ? NetworkHelper::resolveHostname('nextcloud-aio-caddy') : '', 'WHITEBOARD_ENABLED' => $this->isWhiteboardEnabled ? 'yes' : '', 'AIO_VERSION' => $this->getAioVersion(), + 'DESEC_TOKEN' => $this->desecToken, default => $this->getRegisteredSecret($placeholder), }; } 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..ac36ec82 --- /dev/null +++ b/php/src/Desec/DesecManager.php @@ -0,0 +1,361 @@ +guzzleClient = new Client([ + 'timeout' => 15, + 'connect_timeout' => 10, + 'http_errors' => false, + ]); + } + + /** + * Full registration flow: validates inputs, creates an account if needed, + * registers the domain, enables required containers, and updates the DNS record. + * + * When $password is non-empty the user is logging into an existing deSEC account + * rather than creating a new one. When $password is empty a new account is created + * with a randomly generated password (unless an account was already registered in a + * previous attempt). + * + * @return bool true when the domain was fully registered; false when a new account was + * just created and we are now awaiting the user's email verification (a normal, + * non-error outcome — the awaiting-verification UI explains the next step). + * @throws \Exception on any validation or API error + */ + public function register(string $email, string $slug, string $password = ''): bool { + if ($this->configurationManager->domain !== '') { + throw new \Exception('A domain is already configured. Reset the AIO instance first to register a new domain.'); + } + + $validatedSlug = $this->validateSlug($slug); + + [$token, $isNewAccount] = $this->obtainToken($email, $password); + + // An empty token means a brand-new account was created but its email is not yet + // verified. That is not an error: the account state is already persisted, so report + // "awaiting verification" to the caller and let it re-render the awaiting UI. + if ($token === '') { + return false; + } + + $domain = $this->registerDomain($token, $validatedSlug); + + if ($isNewAccount) { + $this->createWildcardCname($token, $domain); + } + + $this->configurationManager->aioCommunityContainers = ["caddy", "dnsmasq"]; + $this->configurationManager->setDomain($domain, true); + $this->updateIpIfDesecDomain(); + + return true; + } + + /** + * Resolves the deSEC API token to use for domain registration, creating or + * logging into an account as needed. + * + * deSEC's POST /auth/ endpoint does NOT return a token: it replies 202 and + * emails a verification link. A token can only be obtained from /auth/login/ + * once the email has been verified. This method therefore drives a small + * state machine across multiple form submissions: + * + * - A token is already stored (a previous attempt got past account setup but + * failed at domain registration) → reuse it. + * - The user supplied a password → log in to their existing, verified account. + * - We are awaiting verification of an account we created earlier → try to log + * in with the stored credentials; succeed once the email is verified, + * otherwise ask the user to verify and try again. + * - Otherwise → create a new account and ask the user to verify their email. + * + * @return array{0: string, 1: bool} the token and whether it belongs to a + * freshly created (AIO-managed) account, which controls wildcard setup. + * @throws \Exception with a user-facing message when verification is pending or on any API error + */ + private function obtainToken(string $email, string $password): array { + if ($this->configurationManager->isDesecAccountRegistered()) { + return [$this->configurationManager->desecToken, false]; + } + + $validatedPassword = trim($password); + + // An account we created earlier is awaiting email verification. + if ($this->configurationManager->isDesecAwaitingVerification()) { + $storedEmail = $this->configurationManager->desecEmail; + // Prefer a freshly entered password (e.g. the user changed it), else the generated one. + $loginPassword = $validatedPassword !== '' ? $validatedPassword : $this->configurationManager->desecPassword; + $token = $this->loginAfterVerification($storedEmail, $loginPassword); + // Storing the token flips the state from "awaiting verification" to "account + // registered" (see ConfigurationManager::isDesecAwaitingVerification()). + $this->configurationManager->desecToken = $token; + return [$token, true]; + } + + $validatedEmail = $this->validateEmail($email); + + if ($validatedPassword !== '') { + // The user supplied their existing deSEC password — log in instead of registering. + // Store an empty password: the token is all we need; the user's password must not be persisted. + $token = $this->loginAccount($validatedEmail, $validatedPassword); + $this->saveAccountCredentials($token, '', $validatedEmail); + return [$token, false]; + } + + // Create a new account. 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. + $generatedPassword = bin2hex(random_bytes(24)); + $this->registerAccount($validatedEmail, $generatedPassword); + // Persisting email + password (but no token, no domain) is exactly the + // "awaiting verification" state, see ConfigurationManager::isDesecAwaitingVerification(). + $this->configurationManager->startTransaction(); + $this->configurationManager->desecPassword = $generatedPassword; + $this->configurationManager->desecEmail = $validatedEmail; + $this->configurationManager->commitTransaction(); + + // This is not an error: the account was requested successfully and we now wait for + // the user to verify their email. Signal it with an empty token so register() can + // stop cleanly and the caller can re-render the awaiting-verification UI (which + // already explains the next step) instead of surfacing an error toast. + return ['', true]; + } + + /** + * Validates an email address string. + * + * @throws \Exception if the email is empty or syntactically invalid + */ + private function validateEmail(string $email): string { + $email = trim($email); + if ($email === '' || filter_var($email, FILTER_VALIDATE_EMAIL) === false) { + throw new \Exception('Please provide a valid email address.'); + } + return $email; + } + + /** + * Validates an optional subdomain slug. + * Returns an empty string when the caller wants a randomly generated slug. + * + * @throws \Exception if the slug is non-empty but does not match the allowed pattern + */ + private function validateSlug(string $slug): string { + $slug = trim($slug); + if ($slug !== '' && !preg_match(self::SLUG_PATTERN, $slug)) { + throw new \Exception( + 'The desired subdomain must contain only lowercase letters, digits and hyphens, ' + . 'be between 1 and 63 characters long, and must not start or end with a hyphen.' + ); + } + return $slug; + } + + /** + * Requests creation of a new deSEC account. + * + * deSEC replies 202 Accepted and emails a verification link; no API token is + * returned here and the account is unusable until the email is verified. For + * privacy reasons deSEC also returns 202 when the email is already registered + * (without sending a mail), so a 202 cannot be treated as proof of a new account. + * The captcha field is omitted; deSEC requires it only at email-verification time, + * which the user completes in the browser via the emailed link. + * + * @throws \Exception on network failure or an unexpected HTTP response + */ + public function registerAccount(string $email, string $password): void { + try { + $res = $this->guzzleClient->post($this->configurationManager->desecApiBase . '/auth/', [ + 'json' => ['email' => $email, 'password' => $password], + ]); + } catch (TransferException $e) { + throw new \Exception('Could not reach the deSEC API: ' . $e->getMessage()); + } + + $code = $res->getStatusCode(); + + if ($code !== 202) { + throw new \Exception('Unexpected response from deSEC during account registration (HTTP ' . $code . '): ' . $res->getBody()->getContents()); + } + } + + /** + * Attempts to log in after the user was asked to verify a freshly created account. + * + * A login failure here has two common causes that we cannot tell apart, because + * deSEC returns 202 both for a genuinely new account and for one whose email was + * already registered (to prevent email enumeration): + * 1. The account is new but its email has not been verified yet. + * 2. The email already belonged to an existing deSEC account, so no new account + * (and no verification mail) was created and our generated password is wrong. + * The message covers both and points to the fix for each. + * + * @throws \Exception with a friendly hint when login is not yet possible + */ + private function loginAfterVerification(string $email, string $password): string { + try { + return $this->loginAccount($email, $password); + } catch (\Exception $e) { + throw new \Exception( + 'Could not log in to deSEC for ' . $email . ' yet. Two things to check:' . "\n" + . '• If deSEC emailed you a verification link, please click it and then try again.' . "\n" + . '• If this email already had a deSEC account, no new account was created. ' + . 'In that case, enter your existing deSEC password in the password field below and try again.' + ); + } + } + + /** + * Authenticates with an existing deSEC account and returns the API token issued for it. + * + * @throws \Exception on invalid credentials, network failure, or an unexpected HTTP response + */ + public function loginAccount(string $email, string $password): string { + try { + $res = $this->guzzleClient->post($this->configurationManager->desecApiBase . '/auth/login/', [ + '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 || $code === 403) { + throw new \Exception('Could not log in to deSEC: invalid email address or password.'); + } + + if ($code !== 200 && $code !== 201) { + throw new \Exception('Unexpected response from deSEC during login (HTTP ' . $code . '): ' . $body); + } + + $data = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($data) || !isset($data['token']) || !is_string($data['token'])) { + throw new \Exception('Could not extract the API token from the deSEC login response. Please try again.'); + } + + return $data['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($this->configurationManager->desecApiBase . '/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.'); + } + + /** + * Creates a wildcard CNAME rrset (*.domain → domain.) for a newly registered domain. + * Errors are logged but do not abort the overall registration. + */ + private function createWildcardCname(string $token, string $domain): void { + try { + $res = $this->guzzleClient->post($this->configurationManager->desecApiBase . '/domains/' . $domain . '/rrsets/', [ + 'headers' => ['Authorization' => 'Token ' . $token], + 'json' => [ + 'subname' => '*', + 'type' => 'CNAME', + 'ttl' => 3600, + 'records' => [$domain . '.'], + ], + ]); + } catch (TransferException $e) { + error_log('Could not create wildcard CNAME for ' . $domain . ': ' . $e->getMessage()); + return; + } + + $code = $res->getStatusCode(); + if ($code !== 201) { + error_log('Unexpected response when creating wildcard CNAME for ' . $domain . ' (HTTP ' . $code . '): ' . $res->getBody()->getContents()); + } + } + + /** + * 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(); + } + + /** + * 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($this->configurationManager->desecUpdateUrl, [ + '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()); + } + } +} From 1868738f56c79f2b807584991e01b916c0df0c91 Mon Sep 17 00:00:00 2001 From: "Simon L." Date: Thu, 18 Jun 2026 13:15:50 +0200 Subject: [PATCH 02/21] feat(desec): add register -> verify -> domain UI - includes/desec-register.twig renders the multi-step deSEC flow: enter email (optionally an existing password), the awaiting-verification step, and the account-registered step, with friendly messaging for the ambiguous "email already registered" case (deSEC returns 202 either way to prevent enumeration). - containers.twig includes the deSEC section under the domain-entry UI; index.php passes the deSEC config/state (email, password, registered/awaiting flags) to the view. - Move the deSEC password reveal from an inline style to a CSS class (no inline CSS/JS in templates) and bump the style.css cache-buster to v13 in layout.twig and log.twig. Co-Authored-By: szaimen <42591237+szaimen@users.noreply.github.com> Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Simon L. --- php/public/index.php | 6 ++++ php/public/style.css | 4 +++ php/templates/containers.twig | 11 ++++++- php/templates/includes/desec-register.twig | 37 ++++++++++++++++++++++ php/templates/layout.twig | 2 +- php/templates/log.twig | 2 +- 6 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 php/templates/includes/desec-register.twig diff --git a/php/public/index.php b/php/public/index.php index 182ef889..9c1a4e72 100644 --- a/php/public/index.php +++ b/php/public/index.php @@ -111,6 +111,7 @@ $app->post('/api/auth/login', AIO\Controller\LoginController::class . ':TryLogin $app->get('/api/auth/getlogin', AIO\Controller\LoginController::class . ':GetTryLogin'); $app->post('/api/auth/logout', AIO\Controller\LoginController::class . ':Logout'); $app->post('/api/configuration', \AIO\Controller\ConfigurationController::class . ':SetConfig'); +$app->post('/api/desec/register', \AIO\Controller\DesecController::class . ':Register'); // Views $app->get('/containers', function (Request $request, Response $response, array $args) use ($container) { @@ -183,6 +184,11 @@ $app->get('/containers', function (Request $request, Response $response, array $ 'community_containers' => $configurationManager->listAvailableCommunityContainers(), 'community_containers_enabled' => $configurationManager->aioCommunityContainers, 'bypass_container_update' => $bypass_container_update, + 'desec_email' => $configurationManager->desecEmail, + 'desec_password' => $configurationManager->desecPassword, + 'is_desec_domain' => $configurationManager->isDesecDomain(), + 'desec_account_registered' => $configurationManager->isDesecAccountRegistered(), + 'desec_awaiting_verification' => $configurationManager->isDesecAwaitingVerification(), // Do not cache the page as it shows credentials ])->withHeader('Cache-Control', 'no-store'); })->setName('profile'); diff --git a/php/public/style.css b/php/public/style.css index 3c657c2c..840cab88 100644 --- a/php/public/style.css +++ b/php/public/style.css @@ -732,6 +732,10 @@ input[type="checkbox"]:disabled:not(:checked) + label { font-weight: 600; } +.inline-details { + display: inline; +} + /* Responsive adjustments for mobile */ @media only screen and (max-width: 800px) { .office-suite-cards { diff --git a/php/templates/containers.twig b/php/templates/containers.twig index 4cd0da5f..35465020 100644 --- a/php/templates/containers.twig +++ b/php/templates/containers.twig @@ -123,7 +123,7 @@

Make sure that this server is reachable on port 443 (port 443/tcp is open/forwarded in your firewall/router and 443/udp as well if you want to enable http3) and that you've correctly set up the DNS config for the domain that you enter (set the A record to your public ipv4-address and if you need ipv6, set the AAAA record to your public ipv6-address. A CNAME record is, of course, also possible). You should see hints on what went wrong in the top right corner if your domain is not accepted.

Click here for further hints -

If you do not have a domain yet, you can get one for free e.g. from duckdns.org and others. Recommended is to use Tailscale

+

If you do not have a domain yet, the easiest option is to use the deSEC free domain registration below. You can also get a free domain from duckdns.org or others, or use Tailscale.

If you have a dynamic public IP-address, you can use e.g. DDclient with a compatible domain provider for DNS updates.

If you only want to install AIO locally without exposing it to the public internet or if you cannot do so, feel free to follow this documentation.

If you should be using Cloudflare Proxy for your domain, make sure to disable the Proxy feature temporarily as it might block the domain validation attempts.

@@ -132,6 +132,7 @@ {% endif %}

Hint: If the domain validation fails but you are completely sure that you've configured everything correctly, you may skip the domain validation by following this documentation.

+ {% include 'includes/desec-register.twig' %} {% endif %}

Restore former AIO instance from backup

@@ -380,6 +381,14 @@ {% if was_start_button_clicked == true %} + {% if is_desec_domain %} +

deSEC account credentials

+

Your domain {{ domain }} is managed via deSEC. Below are your deSEC account credentials. You can use them to log in at desec.io to manage your domain directly.

+

Email: {{ desec_email }}

+

Password:

Reveal deSEC password{{ desec_password }}

+

Please save these credentials in a safe place.

+ {% endif %} + {% if is_backup_section_enabled == false %}

Backup and restore

The backup section is disabled via environmental variable.

diff --git a/php/templates/includes/desec-register.twig b/php/templates/includes/desec-register.twig new file mode 100644 index 00000000..041faa04 --- /dev/null +++ b/php/templates/includes/desec-register.twig @@ -0,0 +1,37 @@ + + Don't have a domain? Get a free one from deSEC +

deSEC offers free dynamic DNS subdomains under dedyn.io. AIO can register an account and a subdomain for you automatically. The caddy community container will be enabled as a reverse proxy, the dnsmasq container will be enabled for local DNS resolution, and the mastercontainer will keep your DNS record up to date automatically.

+ {% if desec_awaiting_verification %} +

⚠️ A deSEC account for {{ desec_email }} was requested but it could not be used yet. Please check your inbox: if deSEC sent you a verification email, click the link inside, then click the button below to finish setting up your domain.

+

If this email address already had a deSEC account, no new account was created and no verification email was sent. In that case, enter your existing deSEC password below and try again to log in with it.

+

Your deSEC login credentials (for desec.io): Email: {{ desec_email }}.

Reveal deSEC password{{ desec_password }}
. Please save these in a safe place.

+
+ + + + + +
+ {% elseif desec_account_registered %} +

Your deSEC account ({{ desec_email }}) was registered successfully but the domain could not be registered. Please enter a desired subdomain slug (the part before .dedyn.io) and try again, or leave it blank for a random one.

+

Your deSEC login credentials (for desec.io): Email: {{ desec_email }}.

Reveal deSEC password{{ desec_password }}
. Please save these in a safe place.

+
+ + + + +
+ {% else %} +

Please enter your email address. You can also enter a desired subdomain slug (the part before .dedyn.io); leave it blank for a random one.

+

If you already have a deSEC account for this email address, enter your deSEC password in the optional password field below to log in with it instead of creating a new account. When creating a new account, deSEC will email you a verification link that you must click before the domain can be set up.

+
+ + + + + + +
+

Note: By submitting this form you agree to the deSEC terms of service. The registered domain and your deSEC account credentials are stored in the AIO configuration. After registration, set your router's DHCP DNS server to this machine's local IP address so LAN devices resolve the domain locally (see the dnsmasq documentation). Alternatively adjust the hosts files on your clients so that they can reach the server using the local ip-address.

+ {% endif %} + diff --git a/php/templates/layout.twig b/php/templates/layout.twig index e79a09dd..5e70ded8 100644 --- a/php/templates/layout.twig +++ b/php/templates/layout.twig @@ -2,7 +2,7 @@ AIO - + diff --git a/php/templates/log.twig b/php/templates/log.twig index e3396d70..ea36ab5c 100644 --- a/php/templates/log.twig +++ b/php/templates/log.twig @@ -2,7 +2,7 @@ AIO - + From 0753c6c902015ee6d30c52cac1466b70389ad2d1 Mon Sep 17 00:00:00 2001 From: "Simon L." Date: Thu, 18 Jun 2026 13:16:06 +0200 Subject: [PATCH 03/21] feat(desec): wire caddy/dnsmasq community containers into the flow - caddy.json: pass DESEC_TOKEN to the caddy container so it can solve the DNS-01 challenge for the registered dedyn.io domain. - community-containers.twig: when the domain was registered via deSEC, explain that caddy and dnsmasq were enabled automatically and link the required dnsmasq router change, with a caution to disable dnsmasq on publicly reachable hosts. - compose.yaml: mention the built-in deSEC free domain option alongside Tailscale in the reverse-proxy hint. Co-Authored-By: szaimen <42591237+szaimen@users.noreply.github.com> Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Simon L. --- community-containers/caddy/caddy.json | 3 ++- compose.yaml | 2 +- php/templates/includes/community-containers.twig | 4 ++++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/community-containers/caddy/caddy.json b/community-containers/caddy/caddy.json index e27df683..4c821064 100644 --- a/community-containers/caddy/caddy.json +++ b/community-containers/caddy/caddy.json @@ -20,7 +20,8 @@ "NC_DOMAIN=%NC_DOMAIN%", "APACHE_PORT=%APACHE_PORT%", "APACHE_IP_BINDING=%APACHE_IP_BINDING%", - "NEXTCLOUD_EXPORTER_CADDY_PASSWORD=%NEXTCLOUD_EXPORTER_CADDY_PASSWORD%" + "NEXTCLOUD_EXPORTER_CADDY_PASSWORD=%NEXTCLOUD_EXPORTER_CADDY_PASSWORD%", + "DESEC_TOKEN=%DESEC_TOKEN%" ], "volumes": [ { diff --git a/compose.yaml b/compose.yaml index c27ca870..a4171ffe 100644 --- a/compose.yaml +++ b/compose.yaml @@ -42,7 +42,7 @@ services: # WATCHTOWER_DOCKER_SOCKET_PATH: /var/run/docker.sock # Needs to be specified if the docker socket on the host is not located in the default '/var/run/docker.sock'. Otherwise mastercontainer updates will fail. For macos it needs to be '/var/run/docker.sock' # # Optional: Caddy reverse proxy. See https://github.com/nextcloud/all-in-one/discussions/575 -# # Alternatively, use Tailscale if you don't have a domain yet. See https://github.com/nextcloud/all-in-one/discussions/6817 +# # Alternatively, if you don't have a domain yet, use the built-in deSEC free domain registration in the AIO interface, or use Tailscale. See https://github.com/nextcloud/all-in-one#how-to-get-a-free-domain-via-desec and https://github.com/nextcloud/all-in-one/discussions/6817 # # Hint: You need to uncomment APACHE_PORT: 11000 above, adjust cloud.example.com to your domain and uncomment the necessary docker volumes at the bottom of this file in order to make it work # # You can find further examples here: https://github.com/nextcloud/all-in-one/discussions/588 # caddy: diff --git a/php/templates/includes/community-containers.twig b/php/templates/includes/community-containers.twig index da1dd26d..2e67d010 100644 --- a/php/templates/includes/community-containers.twig +++ b/php/templates/includes/community-containers.twig @@ -1,6 +1,10 @@

Community Containers

In this section you can enable or disable optional Community Containers that are not included by default in the main installation. These containers are provided by the community and can be useful for various purposes and are automatically integrated in AIOs backup solution and update mechanisms.

⚠️ Caution: Community Containers are maintained by the community and not officially by Nextcloud. Some containers may not be compatible with your system, may not work as expected or may discontinue. Use them at your own risk. Please read the documentation for each container first before adding any as some are also incompatible between each other! Never add all of them at the same time!

+{% if is_desec_domain == true %} +

ℹ️ Your Nextcloud domain ({{ domain }}) was registered via deSEC. The caddy community container has been automatically enabled as a reverse proxy and the dnsmasq container has been automatically enabled so that LAN devices can resolve your Nextcloud domain to the server's local IP address. Please read the dnsmasq documentation for the required router change.

+

⚠️ Caution: if this is a publicly reachable instance, you must disable dnsmasq again as dnsmasq must only run on home servers where port 53 is not publicly exposed.

+{% endif %} {% if isAnyRunning == true %}

Please note: You can enable or disable the options below only when your containers are stopped.

{% else %} From 4c8012adcd054cc895c6d2e2ea3bc61d1ebf205b Mon Sep 17 00:00:00 2001 From: "Simon L." Date: Thu, 18 Jun 2026 13:16:22 +0200 Subject: [PATCH 04/21] test(desec): end-to-end Playwright coverage against a local mock - desec-mock.mjs: a dependency-free Node mock of the deSEC API endpoints the code uses, with the real status-code semantics (202 on account creation, 403 until verified then 200 + token, 201/409 on domain creation) plus /__control hooks to verify and reset state. - desec-register.spec.js / desec-existing.spec.js drive the real AIO UI through the register -> verify -> domain flow and the existing-account login flow; desec-helpers.js holds the shared login helper. Each scenario ends by setting a domain, so they run as separate CI steps with a re-seed in between. - seed-desec-mock-config.php points desec_api_base / desec_update_url at the mock (config key only, no env override, so production is unaffected) and seeds a known master password, since seeding configuration.json makes AIO consider itself already installed and /setup no longer shows a generated password. - Both Playwright workflows start the mock, mount ./community-containers so the caddy/dnsmasq definitions enabled by the flow are present, seed the config, and run the two deSEC specs with a re-seed between them. Co-Authored-By: szaimen <42591237+szaimen@users.noreply.github.com> Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Simon L. --- .github/workflows/playwright-on-push.yml | 66 +++++++++- .../playwright-on-workflow-dispatch.yml | 63 ++++++++- php/tests/desec-mock.mjs | 124 ++++++++++++++++++ php/tests/seed-desec-mock-config.php | 54 ++++++++ php/tests/tests/desec-existing.spec.js | 42 ++++++ php/tests/tests/desec-helpers.js | 22 ++++ php/tests/tests/desec-register.spec.js | 57 ++++++++ 7 files changed, 426 insertions(+), 2 deletions(-) create mode 100644 php/tests/desec-mock.mjs create mode 100644 php/tests/seed-desec-mock-config.php create mode 100644 php/tests/tests/desec-existing.spec.js create mode 100644 php/tests/tests/desec-helpers.js create mode 100644 php/tests/tests/desec-register.spec.js diff --git a/.github/workflows/playwright-on-push.yml b/.github/workflows/playwright-on-push.yml index 755cfb8d..ca120bc5 100644 --- a/.github/workflows/playwright-on-push.yml +++ b/.github/workflows/playwright-on-push.yml @@ -20,6 +20,10 @@ concurrency: env: BASE_URL: https://localhost:8080 + # Master password seeded into configuration.json for the deSEC test. Seeding the config + # makes AIO consider itself already installed, so /setup no longer generates/shows a + # password; the seed helper writes this one and the test logs in with it directly. + AIO_TEST_PASSWORD: correct horse battery staple aircraft jobs: test: @@ -116,7 +120,7 @@ jobs: - name: Run Playwright tests for backup restore run: | - cd php/tests + cd php/tests export DEBUG=pw:api if ! npx playwright test tests/restore-instance.spec.js; then docker logs nextcloud-aio-mastercontainer @@ -124,6 +128,66 @@ jobs: exit 1 fi + - name: Start deSEC mock and fresh development server + run: | + # The deSEC flow must not touch the real deSEC API, so point it at a local mock. + node php/tests/desec-mock.mjs 8090 > desec-mock.log 2>&1 & + echo $! > desec-mock.pid + docker rm --force nextcloud-aio-{mastercontainer,apache,notify-push,nextcloud,redis,database,domaincheck,whiteboard,imaginary,talk,collabora,borgbackup,caddy,dnsmasq} || true + docker volume rm nextcloud_aio_{mastercontainer,apache,database,database_dump,nextcloud,nextcloud_data,redis,backup_cache,elasticsearch} || true + # SKIP_DOMAIN_VALIDATION=false so the deSEC registration form is rendered; the deSEC + # flow sets the domain with validation skipped internally regardless of this. + docker run \ + -d \ + --init \ + --name nextcloud-aio-mastercontainer \ + --restart always \ + --publish 8080:8080 \ + --add-host host.docker.internal:host-gateway \ + --volume nextcloud_aio_mastercontainer:/mnt/docker-aio-config \ + --volume ./php:/var/www/docker-aio/php \ + --volume ./community-containers:/var/www/docker-aio/community-containers \ + --volume ./Containers/mastercontainer/internal.Caddyfile:/internal.Caddyfile \ + --volume ./Containers/mastercontainer/headers.Caddyfile:/headers.Caddyfile \ + --volume ./Containers/mastercontainer/start.sh:/start.sh \ + --volume /var/run/docker.sock:/var/run/docker.sock:ro \ + --env SKIP_DOMAIN_VALIDATION=false \ + --env APACHE_PORT=11000 \ + ghcr.io/nextcloud-releases/all-in-one:develop + echo Waiting for 10 seconds for the development container to start ... + sleep 10 + # Point the running interface at the deSEC mock via configuration.json (config-only, + # no env override). The container runs PHP and has ./php mounted, so use the helper. + # AIO_TEST_PASSWORD is forwarded so the helper can seed the master password too. + docker exec --env AIO_TEST_PASSWORD="$AIO_TEST_PASSWORD" nextcloud-aio-mastercontainer \ + php /var/www/docker-aio/php/tests/seed-desec-mock-config.php \ + http://host.docker.internal:8090/api/v1 http://host.docker.internal:8090/update + + - name: Run Playwright tests for deSEC domain registration + run: | + export DEBUG=pw:api + export DESEC_MOCK_URL=http://localhost:8090 + + # Each deSEC scenario ends by registering a domain, which persists in + # configuration.json and would hide the registration UI for the next scenario. + # Run each spec as its own step and re-seed (reset the deSEC state) in between. + reseed() { + docker exec --env AIO_TEST_PASSWORD="$AIO_TEST_PASSWORD" nextcloud-aio-mastercontainer \ + php /var/www/docker-aio/php/tests/seed-desec-mock-config.php \ + http://host.docker.internal:8090/api/v1 http://host.docker.internal:8090/update + } + dump_logs() { + docker logs nextcloud-aio-mastercontainer + echo "--- deSEC mock log ---" + cat "$GITHUB_WORKSPACE/desec-mock.log" || true + } + + cd php/tests + if ! npx playwright test tests/desec-register.spec.js; then dump_logs; exit 1; fi + reseed + if ! npx playwright test tests/desec-existing.spec.js; then dump_logs; exit 1; fi + kill "$(cat "$GITHUB_WORKSPACE/desec-mock.pid")" || true + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: ${{ !cancelled() }} with: diff --git a/.github/workflows/playwright-on-workflow-dispatch.yml b/.github/workflows/playwright-on-workflow-dispatch.yml index 83880462..afad1c98 100644 --- a/.github/workflows/playwright-on-workflow-dispatch.yml +++ b/.github/workflows/playwright-on-workflow-dispatch.yml @@ -5,6 +5,10 @@ on: env: BASE_URL: https://localhost:8080 + # Master password seeded into configuration.json for the deSEC test. Seeding the config + # makes AIO consider itself already installed, so /setup no longer generates/shows a + # password; the seed helper writes this one and the test logs in with it directly. + AIO_TEST_PASSWORD: correct horse battery staple aircraft jobs: test: @@ -74,7 +78,7 @@ jobs: - name: Run Playwright tests for backup restore run: | - cd php/tests + cd php/tests export DEBUG=pw:api if ! npx playwright test tests/restore-instance.spec.js; then docker logs nextcloud-aio-mastercontainer @@ -82,6 +86,63 @@ jobs: exit 1 fi + - name: Start deSEC mock and fresh development server + run: | + # The deSEC flow must not touch the real deSEC API, so point it at a local mock. + node php/tests/desec-mock.mjs 8090 > desec-mock.log 2>&1 & + echo $! > desec-mock.pid + docker rm --force nextcloud-aio-{mastercontainer,apache,notify-push,nextcloud,redis,database,domaincheck,whiteboard,imaginary,talk,collabora,borgbackup,caddy,dnsmasq} || true + docker volume rm nextcloud_aio_{mastercontainer,apache,database,database_dump,nextcloud,nextcloud_data,redis,backup_cache,elasticsearch} || true + # SKIP_DOMAIN_VALIDATION=false so the deSEC registration form is rendered; the deSEC + # flow sets the domain with validation skipped internally regardless of this. + # Mount ./php/tests so the locally-checked-out DesecManager is exercised. + docker run \ + -d \ + --init \ + --name nextcloud-aio-mastercontainer \ + --restart always \ + --publish 8080:8080 \ + --add-host host.docker.internal:host-gateway \ + --volume nextcloud_aio_mastercontainer:/mnt/docker-aio-config \ + --volume ./php/tests:/var/www/docker-aio/php/tests \ + --volume /var/run/docker.sock:/var/run/docker.sock:ro \ + --env SKIP_DOMAIN_VALIDATION=false \ + --env APACHE_PORT=11000 \ + ghcr.io/nextcloud-releases/all-in-one:develop + echo Waiting for 10 seconds for the development container to start ... + sleep 10 + # Point the running interface at the deSEC mock via configuration.json (config-only, + # no env override). The container runs PHP and has ./php mounted, so use the helper. + # AIO_TEST_PASSWORD is forwarded so the helper can seed the master password too. + docker exec --env AIO_TEST_PASSWORD="$AIO_TEST_PASSWORD" nextcloud-aio-mastercontainer \ + php /var/www/docker-aio/php/tests/seed-desec-mock-config.php \ + http://host.docker.internal:8090/api/v1 http://host.docker.internal:8090/update + + - name: Run Playwright tests for deSEC domain registration + run: | + export DEBUG=pw:api + export DESEC_MOCK_URL=http://localhost:8090 + + # Each deSEC scenario ends by registering a domain, which persists in + # configuration.json and would hide the registration UI for the next scenario. + # Run each spec as its own step and re-seed (reset the deSEC state) in between. + reseed() { + docker exec --env AIO_TEST_PASSWORD="$AIO_TEST_PASSWORD" nextcloud-aio-mastercontainer \ + php /var/www/docker-aio/php/tests/seed-desec-mock-config.php \ + http://host.docker.internal:8090/api/v1 http://host.docker.internal:8090/update + } + dump_logs() { + docker logs nextcloud-aio-mastercontainer + echo "--- deSEC mock log ---" + cat "$GITHUB_WORKSPACE/desec-mock.log" || true + } + + cd php/tests + if ! npx playwright test tests/desec-register.spec.js; then dump_logs; exit 1; fi + reseed + if ! npx playwright test tests/desec-existing.spec.js; then dump_logs; exit 1; fi + kill "$(cat "$GITHUB_WORKSPACE/desec-mock.pid")" || true + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: ${{ !cancelled() }} with: diff --git a/php/tests/desec-mock.mjs b/php/tests/desec-mock.mjs new file mode 100644 index 00000000..fb5267a4 --- /dev/null +++ b/php/tests/desec-mock.mjs @@ -0,0 +1,124 @@ +// Minimal mock of the deSEC API, implementing exactly the endpoints that +// php/src/Desec/DesecManager.php calls, with the same HTTP status-code semantics +// as the real service (see https://desec.readthedocs.io). It exists so the deSEC +// register -> verify -> domain flow can be exercised end-to-end in CI without +// touching the real deSEC service (which would create real accounts, be flaky and +// rate-limited, and require a human to click an email verification link). +// +// We do not probe deSEC's live API (their ToS discourages automated requests, and the +// /api/v1/ interface is contractually stable). Instead, the scheduled workflow +// .github/workflows/desec-api-version-watch.yml watches deSEC's published API-version +// table for changes; if it goes red, review the change and update this mock (plus +// DesecManager) to match. +// +// Usage: node desec-mock.mjs [port] (default port 8090) +// Routes: +// POST /api/v1/auth/ -> 202 (account requested; verify email) +// POST /api/v1/auth/login/ -> 403 until verified, then 200 + {token} +// POST /api/v1/domains/ -> 201 (new name) | 409 (name already taken) +// POST /api/v1/domains/{name}/rrsets/ -> 201 (wildcard CNAME) +// GET /update -> 200 "good" (dyndns update endpoint) +// POST /__control/verify -> test hook: mark accounts email-verified +// POST /__control/reset -> test hook: wipe in-memory state + +import { createServer } from 'node:http' + +const port = Number(process.argv[2] ?? process.env.DESEC_MOCK_PORT ?? 8090) + +// In-memory state. Reset between independent test scenarios via /__control/reset. +const state = { + accounts: new Map(), // email -> { password } + domains: new Set(), // fully-qualified domain names that have been created + verified: false, // simulates whether the (most recent) account's email was verified +} + +function readBody(req) { + return new Promise((resolve) => { + let data = '' + req.on('data', (chunk) => { data += chunk }) + req.on('end', () => { + if (data === '') return resolve({}) + try { resolve(JSON.parse(data)) } catch { resolve({}) } + }) + }) +} + +function send(res, code, body) { + const payload = typeof body === 'string' ? body : JSON.stringify(body) + res.writeHead(code, { 'Content-Type': typeof body === 'string' ? 'text/plain' : 'application/json' }) + res.end(payload) +} + +const server = createServer(async (req, res) => { + const url = new URL(req.url, `http://localhost:${port}`) + const path = url.pathname + const method = req.method ?? 'GET' + + // --- Test control hooks (not part of the real deSEC API) --- + if (method === 'POST' && path === '/__control/verify') { + state.verified = true + return send(res, 200, { verified: true }) + } + if (method === 'POST' && path === '/__control/reset') { + state.accounts.clear() + state.domains.clear() + state.verified = false + return send(res, 200, { reset: true }) + } + + // --- dyndns update endpoint (DesecManager::updateIpIfDesecDomain) --- + if (method === 'GET' && path === '/update') { + return send(res, 200, 'good') + } + + // --- Account registration: deSEC always answers 202 and emails a link; --- + // --- it returns 202 even for an already-registered email (anti-enumeration). --- + if (method === 'POST' && path === '/api/v1/auth/') { + const body = await readBody(req) + if (body.email && !state.accounts.has(body.email)) { + state.accounts.set(body.email, { password: body.password ?? '' }) + // A freshly created account starts unverified; the test flips this via /__control/verify. + state.verified = false + } + return send(res, 202, '') + } + + // --- Login: 403 until the email is verified, then 200 + a token object. --- + if (method === 'POST' && path === '/api/v1/auth/login/') { + const body = await readBody(req) + const account = body.email ? state.accounts.get(body.email) : undefined + const passwordMatches = account && account.password === body.password + if (!state.verified || !passwordMatches) { + return send(res, 403, { detail: 'Invalid email or password, or email not verified.' }) + } + return send(res, 200, { + token: 'mock-token-' + Buffer.from(body.email).toString('hex').slice(0, 16), + id: 'mock-token-id', + created: '2024-01-01T00:00:00.000000Z', + max_age: '7 00:00:00', + }) + } + + // --- Domain creation: 201 for a new name, 409 if the same name already exists. --- + if (method === 'POST' && path === '/api/v1/domains/') { + const body = await readBody(req) + const name = String(body.name ?? '') + if (state.domains.has(name)) { + return send(res, 409, { detail: 'This domain name is unavailable.' }) + } + state.domains.add(name) + return send(res, 201, { name }) + } + + // --- RRset creation (wildcard CNAME): 201. --- + if (method === 'POST' && /^\/api\/v1\/domains\/[^/]+\/rrsets\/$/.test(path)) { + const body = await readBody(req) + return send(res, 201, { subname: body.subname ?? '', type: body.type ?? '', records: body.records ?? [] }) + } + + return send(res, 404, { detail: 'Not found in mock: ' + method + ' ' + path }) +}) + +server.listen(port, '0.0.0.0', () => { + console.log(`deSEC mock listening on http://0.0.0.0:${port}`) +}) diff --git a/php/tests/seed-desec-mock-config.php b/php/tests/seed-desec-mock-config.php new file mode 100644 index 00000000..78d27de0 --- /dev/null +++ b/php/tests/seed-desec-mock-config.php @@ -0,0 +1,54 @@ + + +declare(strict_types=1); + +$apiBase = $argv[1] ?? ''; +$updateUrl = $argv[2] ?? ''; +if ($apiBase === '' || $updateUrl === '') { + fwrite(STDERR, "usage: php seed-desec-mock-config.php \n"); + exit(1); +} + +$password = getenv('AIO_TEST_PASSWORD'); +if ($password === false || $password === '') { + fwrite(STDERR, "AIO_TEST_PASSWORD env var must be set to the master password to seed\n"); + exit(1); +} + +$file = '/mnt/docker-aio-config/data/configuration.json'; +$config = is_file($file) ? json_decode((string)file_get_contents($file), true) : []; +if (!is_array($config)) { + $config = []; +} +$config['desec_api_base'] = $apiBase; +$config['desec_update_url'] = $updateUrl; +$config['password'] = $password; + +// Reset any deSEC state from a previous test run so the registration UI renders again. +unset($config['domain'], $config['desec_email']); +if (isset($config['secrets']) && is_array($config['secrets'])) { + unset($config['secrets']['DESEC_TOKEN'], $config['secrets']['DESEC_PASSWORD']); +} + +file_put_contents($file, json_encode($config, JSON_PRETTY_PRINT)); +echo "Seeded deSEC mock config into $file\n"; diff --git a/php/tests/tests/desec-existing.spec.js b/php/tests/tests/desec-existing.spec.js new file mode 100644 index 00000000..22a05075 --- /dev/null +++ b/php/tests/tests/desec-existing.spec.js @@ -0,0 +1,42 @@ +import { test, expect } from '@playwright/test'; +import { DESEC_MOCK_URL, logInToContainersPage } from './desec-helpers.js'; + +// Exercises the deSEC "I already have a verified account" login path: supplying a valid +// password logs straight in and registers the domain in one step (no email-verification +// round-trip). See desec-register.spec.js for the full setup notes. +// +// This flow also ends by registering a domain, so it is run as its own CI step with the +// deSEC state re-seeded beforehand (configuration.json must have no domain set for the +// registration UI to render). + +test('deSEC existing-account login flow', async ({ page: setupPage }) => { + test.setTimeout(5 * 60 * 1000); + + // Pre-create a verified account in the mock so the password (login) path is exercised + // directly, without the verification round-trip. + await fetch(`${DESEC_MOCK_URL}/__control/reset`, { method: 'POST' }); + const email = `existing-${Math.floor(Math.random() * 2147483647)}@example.com`; + const password = 'correct horse battery staple'; + await fetch(`${DESEC_MOCK_URL}/api/v1/auth/`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + await fetch(`${DESEC_MOCK_URL}/__control/verify`, { method: 'POST' }); + + const containersPage = await logInToContainersPage(setupPage); + + const slug = `aio-existing-${Math.floor(Math.random() * 2147483647)}`; + + await containersPage.getByText("Don't have a domain? Get a free one from deSEC").click(); + await containersPage.locator('input[name="desec_email"]').fill(email); + await containersPage.locator('input[name="desec_password"]').fill(password); + await containersPage.locator('input[name="desec_slug"]').first().fill(slug); + await containersPage.getByRole('button', { name: 'Register free domain via deSEC' }).click(); + + // Supplying a valid password logs straight in and registers the domain in one step. + await expect(containersPage.getByRole('button', { name: 'Download and start containers' })).toBeVisible({ timeout: 60 * 1000 }); + await expect( + containersPage.getByText("Don't have a domain? Get a free one from deSEC"), + ).toHaveCount(0); +}); diff --git a/php/tests/tests/desec-helpers.js b/php/tests/tests/desec-helpers.js new file mode 100644 index 00000000..9332b308 --- /dev/null +++ b/php/tests/tests/desec-helpers.js @@ -0,0 +1,22 @@ +// Shared helpers for the deSEC Playwright scenarios. +// +// The deSEC mock is wired up by seeding configuration.json (see seed-desec-mock-config.php), +// which makes AIO consider itself already installed: /setup no longer renders the +// initial-password page. The seed step therefore writes a known master password (AIO_TEST_PASSWORD) +// that we log in with directly here instead of scraping it from /setup. + +export const DESEC_MOCK_URL = process.env.DESEC_MOCK_URL ?? 'http://localhost:8090'; + +const AIO_PASSWORD = process.env.AIO_TEST_PASSWORD; + +export async function logInToContainersPage(page) { + if (!AIO_PASSWORD) { + throw new Error('AIO_TEST_PASSWORD must be set to the master password seeded into configuration.json'); + } + await page.goto('./'); + await page.locator('#master-password').click(); + await page.locator('#master-password').fill(AIO_PASSWORD); + await page.getByRole('button', { name: 'Log in' }).click(); + await page.waitForURL('./containers'); + return page; +} diff --git a/php/tests/tests/desec-register.spec.js b/php/tests/tests/desec-register.spec.js new file mode 100644 index 00000000..a9d760a7 --- /dev/null +++ b/php/tests/tests/desec-register.spec.js @@ -0,0 +1,57 @@ +import { test, expect } from '@playwright/test'; +import { DESEC_MOCK_URL, logInToContainersPage } from './desec-helpers.js'; + +// Drives the real AIO interface through the full deSEC "register a free domain" flow +// against the local mock (php/tests/desec-mock.mjs). The mastercontainer must be started +// with SKIP_DOMAIN_VALIDATION=false (so the deSEC registration form is rendered) and its +// configuration.json must point desec_api_base / desec_update_url at the mock (see the +// Playwright CI workflow). The mock's control endpoint is reachable from the test runner +// on the host at DESEC_MOCK_URL (default http://localhost:8090). +// +// This flow ends by registering a domain, which persists in configuration.json. It is +// therefore run as its own CI step; the deSEC state is reset (re-seeded) before the +// separate existing-account flow runs. + +test('deSEC register -> verify -> domain flow', async ({ page: setupPage }) => { + test.setTimeout(5 * 60 * 1000); + + // Start from a clean mock state so this test is independent of any other run. + await fetch(`${DESEC_MOCK_URL}/__control/reset`, { method: 'POST' }); + + const containersPage = await logInToContainersPage(setupPage); + + const email = `e2e-${Math.floor(Math.random() * 2147483647)}@example.com`; + const slug = `aio-e2e-${Math.floor(Math.random() * 2147483647)}`; + + // Open the deSEC registration section. + await containersPage.getByText("Don't have a domain? Get a free one from deSEC").click(); + + // 1) Submit email only -> a new account is "created" (mock 202) and AIO asks the user + // to verify their email. This is a normal (non-error) state transition: the page + // reloads into the awaiting-verification UI, which renders inside
and shows + // the dedicated re-submit button. + await containersPage.locator('input[name="desec_email"]').fill(email); + await containersPage.locator('input[name="desec_slug"]').first().fill(slug); + await containersPage.getByRole('button', { name: 'Register free domain via deSEC' }).click(); + await expect(containersPage.getByRole('main')).toContainText('check your inbox', { timeout: 30 * 1000 }); + await expect( + containersPage.getByRole('button', { name: 'I have verified my email – register domain' }), + ).toBeVisible(); + + // 2) Re-submit BEFORE verifying -> login still fails (mock 403) -> friendly hint shown. + await containersPage.getByRole('button', { name: 'I have verified my email – register domain' }).click(); + // Same as above: the failure hint is a transient toast, not
content. + await expect(containersPage.locator('.toast.error')).toContainText('Could not log in to deSEC', { timeout: 8 * 1000 }); + + // 3) Simulate the user clicking the verification link in their email. + const verifyResponse = await fetch(`${DESEC_MOCK_URL}/__control/verify`, { method: 'POST' }); + expect(verifyResponse.status).toBe(200); + + // 4) Re-submit after verification -> login succeeds, domain is registered, the deSEC + // section disappears and the container-start UI appears. + await containersPage.getByRole('button', { name: 'I have verified my email – register domain' }).click(); + await expect(containersPage.getByRole('button', { name: 'Download and start containers' })).toBeVisible({ timeout: 60 * 1000 }); + await expect( + containersPage.getByText("Don't have a domain? Get a free one from deSEC"), + ).toHaveCount(0); +}); From 60230cc6363d1cfe8f565aba6b0e30a6632f314b Mon Sep 17 00:00:00 2001 From: "Simon L." Date: Thu, 18 Jun 2026 13:16:30 +0200 Subject: [PATCH 05/21] ci(desec): add ToS-safe deSEC API version drift guard desec-api-version-watch.yml is a scheduled workflow that watches the public desec-stack "API Versions and Roadmap" table (no calls to the live deSEC API, so it respects their ToS) and fails if it changes, prompting a review of the mock and DesecManager against any new API version. Co-Authored-By: szaimen <42591237+szaimen@users.noreply.github.com> Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Simon L. --- .github/workflows/desec-api-version-watch.yml | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 .github/workflows/desec-api-version-watch.yml diff --git a/.github/workflows/desec-api-version-watch.yml b/.github/workflows/desec-api-version-watch.yml new file mode 100644 index 00000000..770b251a --- /dev/null +++ b/.github/workflows/desec-api-version-watch.yml @@ -0,0 +1,74 @@ +name: deSEC API version watch + +# Watches the authoritative "API Versions and Roadmap" table in the desec-stack README +# for changes (e.g. v2 going stable or v1 getting a deprecation date). The deSEC flow is +# tested in CI against a mock (php/tests/desec-mock.mjs); this job catches upstream API +# drift WITHOUT making automated requests to deSEC's live API (their ToS discourages that, +# and /api/v1/ is contractually stable). It only fetches a public GitHub README. +# +# Runs on a schedule (and manual dispatch) only, never on PR/push, so it never blocks a +# merge. When the table changes the run goes red — a maintainer then reviews the change, +# updates the mock + DesecManager if the API surface changed, and updates the EXPECTED +# heredoc below to re-green this job. + +on: + schedule: + # Weekly, Mondays 05:17 UTC (off the hour to avoid scheduler congestion). + - cron: '17 5 * * 1' + workflow_dispatch: + +jobs: + watch: + name: Compare deSEC API version table against baseline + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check deSEC API version table + run: | + set -euo pipefail + + # The last-reviewed state of the desec-stack README "API Versions and Roadmap" + # table. Whitespace is normalized to single spaces. When this job fails, review + # the upstream change and update this block to match (after updating the mock / + # DesecManager if the API surface actually changed). + expected() { + cat <<'EOF' + API Version | URL Prefix | Status | Support Ends + ----------- | ---------- | --------- | ------------ + Version 1 | `/api/v1/` | stable | earliest 6 months after v2 is declared stable + Version 2 | `/api/v2/` | unstable + EOF + } + + readme="$(curl -fsSL https://raw.githubusercontent.com/desec-io/desec-stack/main/README.md)" + + # Extract the table: from the "API Versions and Roadmap" heading, take the block + # of pipe-containing lines and stop at the first pipe-less line after it begins. + # Then collapse runs of whitespace to a single space and trim each line. + current="$(printf '%s\n' "$readme" | awk ' + /^API Versions and Roadmap[[:space:]]*$/ { insection=1; next } + insection { + if (index($0, "|") > 0) { started=1; print; next } + if (started) { exit } + } + ' | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//')" + + if [ -z "$current" ]; then + echo "Could not extract the API version table from the desec-stack README." + echo "The README layout may have changed — review it manually:" + echo " https://github.com/desec-io/desec-stack#api-versions-and-roadmap" + exit 1 + fi + + if diff <(expected) <(printf '%s\n' "$current"); then + echo "ok - deSEC API version table is unchanged." + exit 0 + fi + + echo + echo "CHANGED - the deSEC API version table differs from the reviewed baseline." + echo "The deSEC API version status changed (e.g. v2 going stable or v1 getting a" + echo "deprecation date). Review the change, update php/tests/desec-mock.mjs and" + echo "php/src/Desec/DesecManager.php if the API surface changed, then update the" + echo "EXPECTED heredoc in this workflow to match upstream." + exit 1 From 164b1508175d3847a54d8baecb9c409be5d643e2 Mon Sep 17 00:00:00 2001 From: "Simon L." Date: Thu, 18 Jun 2026 13:16:42 +0200 Subject: [PATCH 06/21] docs(desec): document the free deSEC domain option Mention and recommend the built-in deSEC free domain registration across the readme, reverse-proxy and local-instance guides, and add a deSEC step to the new-instance QA checklist. Co-Authored-By: szaimen <42591237+szaimen@users.noreply.github.com> Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Simon L. --- local-instance.md | 19 +++++++++++-------- readme.md | 30 +++++++++++++++++++++++++----- reverse-proxy.md | 4 ++-- tests/QA/002-new-instance.md | 7 +++++++ 4 files changed, 45 insertions(+), 15 deletions(-) diff --git a/local-instance.md b/local-instance.md index acf6cea4..e9bd6ed3 100644 --- a/local-instance.md +++ b/local-instance.md @@ -2,22 +2,25 @@ It is possible due to several reasons that you do not want or cannot open Nextcloud to the public internet. Perhaps you were hoping to access AIO directly from an `ip.add.r.ess` (unsupported) or without a valid domain. However, AIO requires a valid certificate to work correctly. Below is discussed how you can achieve both: Having a valid certificate for Nextcloud and only using it locally. ### Content -- [1. Tailscale](#1-tailscale) -- [2. Pangolin](#2-pangolin) +- [1. deSEC free domain (recommended)](#1-desec-free-domain-recommended) +- [2. Tailscale](#2-tailscale) - [3. The normal way](#3-the-normal-way) - [4. Use the ACME DNS-challenge](#4-use-the-acme-dns-challenge) - [5. Use Cloudflare](#5-use-cloudflare) - [6. Buy a certificate and use that](#6-buy-a-certificate-and-use-that) -## 1. Tailscale -This is the recommended way. For a reverse proxy example guide for Tailscale, see this guide by [@Perseus333](https://github.com/Perseus333): https://github.com/nextcloud/all-in-one/discussions/6817 +## 1. deSEC free domain (recommended) +[deSEC](https://desec.io) offers free dynamic-DNS subdomains under `dedyn.io`. AIO can register an account and a subdomain for you automatically — directly from the domain-entry page of the AIO interface. After registration: +- The [Caddy](https://github.com/nextcloud/all-in-one/tree/main/community-containers/caddy) community container is enabled automatically as a reverse proxy and handles TLS via Let's Encrypt. +- The [dnsmasq](https://github.com/nextcloud/all-in-one/tree/main/community-containers/dnsmasq) community container is enabled automatically so that LAN clients resolve your Nextcloud domain to the server's local IP address — no separate Pi-hole or local DNS server required. +- The mastercontainer keeps the DNS record up to date automatically when your public IP changes. -## 2. Pangolin -[Pangolin](https://pangolin.net/) is an open-source, WireGuard-based remote access platform similar in concept to Tailscale. It uses the **Newt** connector to create outbound-only encrypted tunnels — no inbound ports need to be opened on your firewall. Pangolin handles TLS automatically, providing a valid certificate for your Nextcloud domain. +**How to set it up:** Open the AIO interface, expand the **"Don't have a domain? Get a free one from deSEC"** section, enter your email and an optional subdomain slug, and click **Register free domain via deSEC**. See the full documentation at [How to get a free domain via deSEC](https://github.com/nextcloud/all-in-one#how-to-get-a-free-domain-via-desec). -You can use either [Pangolin Cloud](https://app.pangolin.net/) (free tier available) or [self-host your own Pangolin server](https://docs.pangolin.net/self-host/quick-install) on a VPS. For private/local-only access, self-hosting Pangolin on a machine within your local network means that Nextcloud never needs to be exposed to the public internet. +After registration, follow the [dnsmasq documentation](https://github.com/nextcloud/all-in-one/tree/main/community-containers/dnsmasq) to point your router's DHCP DNS server to the AIO host so that all LAN devices resolve the domain locally. -For the reverse proxy configuration details and a step-by-step setup guide, see the [Pangolin section in the reverse proxy documentation](./reverse-proxy.md#pangolin). +## 2. Tailscale +For a reverse proxy example guide for Tailscale, see this guide by [@Perseus333](https://github.com/Perseus333): https://github.com/nextcloud/all-in-one/discussions/6817 ## 3. The normal way The normal way is the following: diff --git a/readme.md b/readme.md index ab272da9..a3729a54 100644 --- a/readme.md +++ b/readme.md @@ -61,6 +61,7 @@ Included are: - [Mail server can be added](https://github.com/nextcloud/all-in-one#mail-server) - Nextcloud can be [accessed locally via the domain](https://github.com/nextcloud/all-in-one#how-can-i-access-nextcloud-locally) - Can [be installed locally](https://github.com/nextcloud/all-in-one/blob/main/local-instance.md) (if you don't want or cannot make the instance publicly reachable) +- Free [deSEC](https://desec.io) dynamic-DNS domain (`*.dedyn.io`) can be registered directly from the AIO interface — no external domain needed - [IPv6-ready](https://github.com/nextcloud/all-in-one/blob/main/docker-ipv6-support.md) - Can be used with [Docker rootless](https://github.com/nextcloud/all-in-one/blob/main/docker-rootless.md) (good for additional security) - Runs on all platforms Docker supports (e.g. also on Windows and Macos) @@ -249,6 +250,9 @@ https://your-domain-that-points-to-this-server.tld:8443 5. If you enable Nextcloud Talk, open port `3478/TCP` and `3478/UDP` in your firewall/router for the Talk (TURN) container. +> [!TIP] +> Don't have a domain yet? AIO can register a free dynamic-DNS subdomain under `dedyn.io` for you via [deSEC](https://desec.io) — no external setup needed. Look for the **"Don't have a domain? Get a free one from deSEC"** section on the AIO interface when you are asked to enter your domain. AIO will automatically register the domain, keep the DNS record up to date, and enable the [Caddy](https://github.com/nextcloud/all-in-one/tree/main/community-containers/caddy) community container as a reverse proxy as well as the [dnsmasq](https://github.com/nextcloud/all-in-one/tree/main/community-containers/dnsmasq) container for local DNS resolution. + # FAQ - [TOC](#faq) - [Where can I find additional documentation?](#where-can-i-find-additional-documentation) @@ -262,6 +266,7 @@ https://your-domain-that-points-to-this-server.tld:8443 - [Notes on Cloudflare (proxy/tunnel)](#notes-on-cloudflare-proxytunnel) - [How to run Nextcloud behind a Cloudflare Tunnel?](#how-to-run-nextcloud-behind-a-cloudflare-tunnel) - [How to run Nextcloud via Tailscale?](#how-to-run-nextcloud-via-tailscale) + - [How to get a free domain via deSEC?](#how-to-get-a-free-domain-via-desec) - [How to get Nextcloud running using the ACME DNS-challenge?](#how-to-get-nextcloud-running-using-the-acme-dns-challenge) - [How to run Nextcloud locally? No domain wanted, or wanting intranet access within your LAN.](#how-to-run-nextcloud-locally-no-domain-wanted-or-wanting-intranet-access-within-your-lan) - [Can I use an ip-address for Nextcloud instead of a domain?](#can-i-use-an-ip-address-for-nextcloud-instead-of-a-domain) @@ -392,7 +397,7 @@ Only those (if you access the Mastercontainer Interface internally via port 8080 - `3478/TCP` and `3478/UDP`: will be used by the Turnserver inside the Talk container and needs to be open/forwarded in your firewall/router ### Notes on Cloudflare (proxy/tunnel) -Since Cloudflare Proxy/Tunnel comes with a lot of limitations which are listed below, it is rather recommended to switch to [Tailscale](https://github.com/nextcloud/all-in-one/discussions/6817) if possible. +Since Cloudflare Proxy/Tunnel comes with a lot of limitations which are listed below, it is rather recommended to use the [built-in deSEC domain registration](#how-to-get-a-free-domain-via-desec) or switch to [Tailscale](https://github.com/nextcloud/all-in-one/discussions/6817) if possible. - Cloudflare Proxy and Cloudflare Tunnel both require Cloudflare to perform TLS termination on their side and thus decrypt all the traffic on their infrastructure. This is a privacy concern and you will need to look for other solutions if it's unacceptable for you. - Using Cloudflare Tunnel might potentially slow down Nextcloud since local access via the configured domain is not possible because TLS termination is in that case offloaded to Cloudflare's infrastructure. There is no way to disable this behavior in Cloudflare Tunnel. - It is known that the domain validation may not work correctly behind Cloudflare since Cloudflare might block the validation attempt. You can simply skip it in that case by following: https://github.com/nextcloud/all-in-one#how-to-skip-the-domain-validation @@ -412,6 +417,19 @@ Although it does not seems like it is the case but from AIO perspective a Cloudf ### How to run Nextcloud via Tailscale? For a reverse proxy example guide for Tailscale, see this guide by [@Perseus333](https://github.com/Perseus333): https://github.com/nextcloud/all-in-one/discussions/6817 +### How to get a free domain via deSEC? +[deSEC](https://desec.io) offers free dynamic-DNS subdomains under `dedyn.io`. AIO integrates the registration process directly: + +1. Open the AIO interface and expand the **"Don't have a domain? Get a free one from deSEC"** section on the domain-entry page. +2. Enter your email address and, optionally, a desired subdomain slug (the part before `.dedyn.io`). Leave the slug blank for a random one. +3. Click **Register free domain via deSEC**. AIO will create a deSEC account, register the subdomain and store the credentials in its configuration. + +After successful registration: +- AIO sets the registered `*.dedyn.io` domain as the Nextcloud domain automatically. +- The [Caddy](https://github.com/nextcloud/all-in-one/tree/main/community-containers/caddy) community container is enabled as a reverse proxy. +- The [dnsmasq](https://github.com/nextcloud/all-in-one/tree/main/community-containers/dnsmasq) community container is enabled so that LAN devices can resolve the domain to the server's local IP. Follow the [dnsmasq documentation](https://github.com/nextcloud/all-in-one/tree/main/community-containers/dnsmasq) for the required router change. +- The mastercontainer keeps the DNS record up to date automatically when your public IP changes. + ### How to get Nextcloud running using the ACME DNS-challenge? You can install AIO behind an external reverse proxy where is also documented how to get it running using the ACME DNS-challenge for getting a valid certificate for AIO. See the [reverse proxy documentation](./reverse-proxy.md). (Meant is the `Caddy with ACME DNS-challenge` section). Also see https://github.com/dani-garcia/vaultwarden/wiki/Running-a-private-vaultwarden-instance-with-Let%27s-Encrypt-certs#getting-a-custom-caddy-build for additional docs on this topic. @@ -419,22 +437,22 @@ You can install AIO behind an external reverse proxy where is also documented ho If you do not want to open Nextcloud to the public internet, you may have a look at the following documentation on how to set it up locally: [local-instance.md](./local-instance.md), but keep in mind you're still required to have https working properly. ### Can I use an ip-address for Nextcloud instead of a domain? -No and it will not be added. If you only want to run it locally, you may have a look at the following documentation: [local-instance.md](./local-instance.md). Recommended is to use [Tailscale](https://github.com/nextcloud/all-in-one/discussions/6817). +No and it will not be added. If you only want to run it locally, you may have a look at the following documentation: [local-instance.md](./local-instance.md). Recommended is to use the [built-in deSEC domain registration](#how-to-get-a-free-domain-via-desec) to get a free domain automatically, or alternatively [Tailscale](https://github.com/nextcloud/all-in-one/discussions/6817). ### Can I run AIO offline or in an airgapped system? No. This is not possible and will not be added due to multiple reasons: update checks, app installs via app-store, downloading additional docker images on demand and more. ### Are self-signed certificates supported for Nextcloud? -No and they will not be. If you want to run it locally, without opening Nextcloud to the public internet, please have a look at the [local instance documentation](./local-instance.md). Recommended is to use [Tailscale](https://github.com/nextcloud/all-in-one/discussions/6817). +No and they will not be. If you want to run it locally, without opening Nextcloud to the public internet, please have a look at the [local instance documentation](./local-instance.md). Recommended is to use the [built-in deSEC domain registration](#how-to-get-a-free-domain-via-desec) to obtain a free domain with a valid certificate automatically, or alternatively [Tailscale](https://github.com/nextcloud/all-in-one/discussions/6817). ### Can I use AIO with multiple domains? No and it will not be added. However you can use [this feature](https://github.com/nextcloud/all-in-one/blob/main/multiple-instances.md) in order to create multiple AIO instances, one for each domain. ### Are other ports than the default 443 for Nextcloud supported? -No and they will not be. If port 443 and/or 80 is blocked for you, you may use [Tailscale](https://github.com/nextcloud/all-in-one/discussions/6817) if you want to publish it online. If you already run a different service on port 443, please use a dedicated domain for Nextcloud and set it up correctly by following the [reverse proxy documentation](./reverse-proxy.md). However in all cases the Nextcloud interface will redirect you to port 443. +No and they will not be. If port 443 and/or 80 is blocked for you, you may use the [built-in deSEC domain registration](#how-to-get-a-free-domain-via-desec) (which uses the Caddy community container on port 443) or [Tailscale](https://github.com/nextcloud/all-in-one/discussions/6817) if you want to publish it online. If you already run a different service on port 443, please use a dedicated domain for Nextcloud and set it up correctly by following the [reverse proxy documentation](./reverse-proxy.md). However in all cases the Nextcloud interface will redirect you to port 443. ### Can I run Nextcloud in a subdirectory on my domain? -No and it will not be added. Please use a dedicated (sub-)domain for Nextcloud and set it up correctly by following the [reverse proxy documentation](./reverse-proxy.md). Alternatively, you may use [Tailscale](https://github.com/nextcloud/all-in-one/discussions/6817) if you want to publish it online. +No and it will not be added. Please use a dedicated (sub-)domain for Nextcloud and set it up correctly by following the [reverse proxy documentation](./reverse-proxy.md). If you don't have a domain yet, use the [built-in deSEC domain registration](#how-to-get-a-free-domain-via-desec) to get one for free, or alternatively [Tailscale](https://github.com/nextcloud/all-in-one/discussions/6817). ### How can I access Nextcloud locally? Please note that local access is not possible if you are running AIO behind Cloudflare Tunnel since TLS proxying is in that case offloaded to Cloudflares infrastructure. You can fix this by setting up your own reverse proxy that handles TLS proxying locally and will make the steps below work. @@ -448,6 +466,8 @@ Now that this is out of the way, the recommended way how to access Nextcloud loc - https://dockerlabs.collabnix.com/intermediate/networking/Configuring_DNS.html Apart from that there is now a community container that can be added to the AIO stack: https://github.com/nextcloud/all-in-one/tree/main/community-containers/pi-hole +If you registered your domain via the built-in [deSEC integration](#how-to-get-a-free-domain-via-desec), the [dnsmasq](https://github.com/nextcloud/all-in-one/tree/main/community-containers/dnsmasq) community container is automatically enabled and resolves your Nextcloud domain to the server's local IP for LAN clients. You only need to point your router's DHCP DNS server to the AIO host — see the [dnsmasq documentation](https://github.com/nextcloud/all-in-one/tree/main/community-containers/dnsmasq) for details. + ### How to overwrite the local DNS resolution for some domains or add extra hosts to the containers? For some use cases, you might need to overwrite the local DNS resolution of some domains inside the containers. On Linux, you can do so either by using a local DNS server as described in the section above and add a local DNS entry into the dns server and make your containers use that DNS server. diff --git a/reverse-proxy.md b/reverse-proxy.md index 05c7bbcf..345228c2 100644 --- a/reverse-proxy.md +++ b/reverse-proxy.md @@ -108,7 +108,7 @@ To make your Nextcloud AIO instance accessible from the public Internet (not jus ## Configuration and Deployment > [!NOTE] -> These instructions assume you already have a domain name pointing to your server's public IP address. If you don't have a domain yet, see the recommendations below. +> These instructions assume you already have a domain name pointing to your server's public IP address. If you don't have a domain yet, AIO can register a free dynamic-DNS subdomain under `dedyn.io` for you via the built-in [deSEC integration](https://github.com/nextcloud/all-in-one#how-to-get-a-free-domain-via-desec) — no external setup needed. Alternatively, see the recommendations below. ### Quick overview @@ -121,7 +121,7 @@ To run Nextcloud AIO behind an external reverse proxy or secure tunneling/proxyi The sections below provide detailed instructions for each step. > [!TIP] -> If you don't have a domain yet, we recommend using [an approach using Tailscale](https://github.com/nextcloud/all-in-one/discussions/6817). If you don't have an external reverse proxy yet, we recommend [Caddy](https://github.com/nextcloud/all-in-one/discussions/575). +> If you don't have a domain yet, AIO can register a free `*.dedyn.io` subdomain for you via [deSEC](https://desec.io) directly from the AIO interface — see [How to get a free domain via deSEC](https://github.com/nextcloud/all-in-one#how-to-get-a-free-domain-via-desec). This is the recommended option. Alternatively, you can use [Tailscale](https://github.com/nextcloud/all-in-one/discussions/6817). If you don't have an external reverse proxy yet, we recommend [Caddy](https://github.com/nextcloud/all-in-one/discussions/575). ### Step-by-Step Instructions diff --git a/tests/QA/002-new-instance.md b/tests/QA/002-new-instance.md index bc1068fc..f8210895 100644 --- a/tests/QA/002-new-instance.md +++ b/tests/QA/002-new-instance.md @@ -9,6 +9,13 @@ For the below to work, it is important that you have a domain that you point ont - [ ] Entering `10.0.0.1` should report that ip-addresses are not supported - [ ] Entering `nextcloud.com` should report that the domain does not point to this server - [ ] Entering the domain that does point to your server e.g. `yourdomain.com` should finally redirect you to the next screen (if you did not configure your domain yet or did not open port 443, it should report that to you) +- [ ] Below the domain input box there should be a collapsed `
` element labelled **"Don't have a domain? Get a free one from deSEC"** + - [ ] Expanding it should show a form with fields for email address and an optional subdomain slug, plus a **Register free domain via deSEC** submit button + - [ ] Submitting with an empty email should show an error asking for a valid email address + - [ ] Submitting with an invalid slug (e.g. `-bad-`) should show a validation error about the allowed slug format + - [ ] Submitting with a valid email and no slug should register a random `*.dedyn.io` domain, set it as the Nextcloud domain and redirect to the next screen + - [ ] After successful registration, the `caddy` and `dnsmasq` community containers should be listed as enabled in the Community Containers section + - [ ] After successful registration, there should be a **deSEC account credentials** section in the AIO interface that shows the registered email and allows revealing the password - [ ] Now you should see a button `Start containers` and an explanation which points out that clicking on the button will start the containers and that this can take a long time. - [ ] Below that you should see a section `Optional addons` which shows a checkbox list with addons that can be enabled or disabled. - [ ] Collabora, Imaginary, Talk and Whiteboard should be enabled, the rest disabled From 454905c8525c14fa33ff4d3d25504ef3199ea39f Mon Sep 17 00:00:00 2001 From: "Simon L." Date: Mon, 22 Jun 2026 13:22:37 +0200 Subject: [PATCH 07/21] feat(desec): run registration flow in a modal iframe Move the deSEC "register a free domain" flow out of the inline containers page form and into a modal backed by a dedicated /desec view loaded in an iframe. The multi-step register -> verify -> domain process now re-renders inside the modal, so the user can adjust the details and complete email verification without reloading the whole page each step. Only once the domain is fully registered does the view reload the parent containers page. - add /desec route + desec.twig standalone view - add desec-modal.js (open/close, backdrop + Escape) and desec-done.js (parent reload on completion) - redirect register POST to the /desec view so steps stay in the modal - drop the redundant
wrapper in desec-register.twig, add heading - style the modal and let
- {% include 'includes/desec-register.twig' %} +
+ Don't have a domain? Get a free one from deSEC +

deSEC offers free dynamic DNS subdomains under dedyn.io. AIO can register an account and a subdomain for you automatically. Click the button below to start; the registration runs in a small window so you can adjust the details and complete the email verification without losing this page. Once it's done, this page reloads with your new domain configured.

+ {% if desec_awaiting_verification or desec_account_registered %} +

⚠️ A deSEC registration for {{ desec_email }} is already in progress but not finished yet. Click the button below to continue it.

+ + {% else %} + + {% endif %} +
+ + {% endif %}

Restore former AIO instance from backup

diff --git a/php/templates/desec.twig b/php/templates/desec.twig new file mode 100644 index 00000000..70c96259 --- /dev/null +++ b/php/templates/desec.twig @@ -0,0 +1,30 @@ + + + + Register a free deSEC domain + + + + + + + + + {% if is_desec_domain %} + {# The whole process finished: a deSEC domain is now configured. Tell the parent + window (the modal opener) to reload so the full containers page reflects the + new domain, then close the modal. When this view is opened directly (not in an + iframe) window.top === window, so we just reload ourselves. #} +

Your free domain {{ domain }} was registered successfully. Finishing up…

+ + {% else %} +
+ {% include 'includes/desec-register.twig' %} +
+ {% endif %} +
+
+ +
+ + diff --git a/php/templates/includes/desec-register.twig b/php/templates/includes/desec-register.twig index 041faa04..c52d7493 100644 --- a/php/templates/includes/desec-register.twig +++ b/php/templates/includes/desec-register.twig @@ -1,37 +1,35 @@ - - Don't have a domain? Get a free one from deSEC -

deSEC offers free dynamic DNS subdomains under dedyn.io. AIO can register an account and a subdomain for you automatically. The caddy community container will be enabled as a reverse proxy, the dnsmasq container will be enabled for local DNS resolution, and the mastercontainer will keep your DNS record up to date automatically.

- {% if desec_awaiting_verification %} -

⚠️ A deSEC account for {{ desec_email }} was requested but it could not be used yet. Please check your inbox: if deSEC sent you a verification email, click the link inside, then click the button below to finish setting up your domain.

-

If this email address already had a deSEC account, no new account was created and no verification email was sent. In that case, enter your existing deSEC password below and try again to log in with it.

-

Your deSEC login credentials (for desec.io): Email: {{ desec_email }}.

Reveal deSEC password{{ desec_password }}
. Please save these in a safe place.

-
- - - - - -
- {% elseif desec_account_registered %} -

Your deSEC account ({{ desec_email }}) was registered successfully but the domain could not be registered. Please enter a desired subdomain slug (the part before .dedyn.io) and try again, or leave it blank for a random one.

-

Your deSEC login credentials (for desec.io): Email: {{ desec_email }}.

Reveal deSEC password{{ desec_password }}
. Please save these in a safe place.

-
- - - - -
- {% else %} -

Please enter your email address. You can also enter a desired subdomain slug (the part before .dedyn.io); leave it blank for a random one.

-

If you already have a deSEC account for this email address, enter your deSEC password in the optional password field below to log in with it instead of creating a new account. When creating a new account, deSEC will email you a verification link that you must click before the domain can be set up.

-
- - - - - - -
-

Note: By submitting this form you agree to the deSEC terms of service. The registered domain and your deSEC account credentials are stored in the AIO configuration. After registration, set your router's DHCP DNS server to this machine's local IP address so LAN devices resolve the domain locally (see the dnsmasq documentation). Alternatively adjust the hosts files on your clients so that they can reach the server using the local ip-address.

- {% endif %} -
+

Get a free domain from deSEC

+

deSEC offers free dynamic DNS subdomains under dedyn.io. AIO can register an account and a subdomain for you automatically. The caddy community container will be enabled as a reverse proxy, the dnsmasq container will be enabled for local DNS resolution, and the mastercontainer will keep your DNS record up to date automatically.

+{% if desec_awaiting_verification %} +

⚠️ A deSEC account for {{ desec_email }} was requested but it could not be used yet. Please check your inbox: if deSEC sent you a verification email, click the link inside, then click the button below to finish setting up your domain.

+

If this email address already had a deSEC account, no new account was created and no verification email was sent. In that case, enter your existing deSEC password below and try again to log in with it.

+

Your deSEC login credentials (for desec.io): Email: {{ desec_email }}.

Reveal deSEC password{{ desec_password }}
. Please save these in a safe place.

+
+ + + + + +
+{% elseif desec_account_registered %} +

Your deSEC account ({{ desec_email }}) was registered successfully but the domain could not be registered. Please enter a desired subdomain slug (the part before .dedyn.io) and try again, or leave it blank for a random one.

+

Your deSEC login credentials (for desec.io): Email: {{ desec_email }}.

Reveal deSEC password{{ desec_password }}
. Please save these in a safe place.

+
+ + + + +
+{% else %} +

Please enter your email address. You can also enter a desired subdomain slug (the part before .dedyn.io); leave it blank for a random one.

+

If you already have a deSEC account for this email address, enter your deSEC password in the optional password field below to log in with it instead of creating a new account. When creating a new account, deSEC will email you a verification link that you must click before the domain can be set up.

+
+ + + + + + +
+

Note: By submitting this form you agree to the deSEC terms of service. The registered domain and your deSEC account credentials are stored in the AIO configuration. After registration, set your router's DHCP DNS server to this machine's local IP address so LAN devices resolve the domain locally (see the dnsmasq documentation). Alternatively adjust the hosts files on your clients so that they can reach the server using the local ip-address.

+{% endif %} diff --git a/php/tests/tests/desec-existing.spec.js b/php/tests/tests/desec-existing.spec.js index 22a05075..7c5af394 100644 --- a/php/tests/tests/desec-existing.spec.js +++ b/php/tests/tests/desec-existing.spec.js @@ -5,6 +5,9 @@ import { DESEC_MOCK_URL, logInToContainersPage } from './desec-helpers.js'; // password logs straight in and registers the domain in one step (no email-verification // round-trip). See desec-register.spec.js for the full setup notes. // +// As in the register flow, the form is driven inside the modal iframe (the /desec view); +// on success the iframe reloads the parent containers page. +// // This flow also ends by registering a domain, so it is run as its own CI step with the // deSEC state re-seeded beforehand (configuration.json must have no domain set for the // registration UI to render). @@ -28,13 +31,18 @@ test('deSEC existing-account login flow', async ({ page: setupPage }) => { const slug = `aio-existing-${Math.floor(Math.random() * 2147483647)}`; + // Open the deSEC registration entry point and launch the modal. await containersPage.getByText("Don't have a domain? Get a free one from deSEC").click(); - await containersPage.locator('input[name="desec_email"]').fill(email); - await containersPage.locator('input[name="desec_password"]').fill(password); - await containersPage.locator('input[name="desec_slug"]').first().fill(slug); await containersPage.getByRole('button', { name: 'Register free domain via deSEC' }).click(); - // Supplying a valid password logs straight in and registers the domain in one step. + const frame = containersPage.frameLocator('#desec-frame'); + await frame.locator('input[name="desec_email"]').fill(email); + await frame.locator('input[name="desec_password"]').fill(password); + await frame.locator('input[name="desec_slug"]').first().fill(slug); + await frame.getByRole('button', { name: 'Register free domain via deSEC' }).click(); + + // Supplying a valid password logs straight in and registers the domain in one step; the + // modal view then reloads the parent containers page. await expect(containersPage.getByRole('button', { name: 'Download and start containers' })).toBeVisible({ timeout: 60 * 1000 }); await expect( containersPage.getByText("Don't have a domain? Get a free one from deSEC"), diff --git a/php/tests/tests/desec-register.spec.js b/php/tests/tests/desec-register.spec.js index a9d760a7..8c4816b0 100644 --- a/php/tests/tests/desec-register.spec.js +++ b/php/tests/tests/desec-register.spec.js @@ -3,11 +3,16 @@ import { DESEC_MOCK_URL, logInToContainersPage } from './desec-helpers.js'; // Drives the real AIO interface through the full deSEC "register a free domain" flow // against the local mock (php/tests/desec-mock.mjs). The mastercontainer must be started -// with SKIP_DOMAIN_VALIDATION=false (so the deSEC registration form is rendered) and its -// configuration.json must point desec_api_base / desec_update_url at the mock (see the +// with SKIP_DOMAIN_VALIDATION=false (so the deSEC registration entry point is rendered) and +// its configuration.json must point desec_api_base / desec_update_url at the mock (see the // Playwright CI workflow). The mock's control endpoint is reachable from the test runner // on the host at DESEC_MOCK_URL (default http://localhost:8090). // +// The registration runs inside a modal iframe (the /desec view): the multi-step +// register -> verify -> domain flow re-renders inside the iframe so the user can adjust the +// details and complete email verification without reloading the whole containers page. Only +// once the domain is fully registered does the iframe reload the parent page. +// // This flow ends by registering a domain, which persists in configuration.json. It is // therefore run as its own CI step; the deSEC state is reset (re-seeded) before the // separate existing-account flow runs. @@ -23,33 +28,37 @@ test('deSEC register -> verify -> domain flow', async ({ page: setupPage }) => { const email = `e2e-${Math.floor(Math.random() * 2147483647)}@example.com`; const slug = `aio-e2e-${Math.floor(Math.random() * 2147483647)}`; - // Open the deSEC registration section. + // Open the deSEC registration entry point and launch the modal. await containersPage.getByText("Don't have a domain? Get a free one from deSEC").click(); + await containersPage.getByRole('button', { name: 'Register free domain via deSEC' }).click(); + + // The flow lives inside the modal iframe (the /desec view). + const frame = containersPage.frameLocator('#desec-frame'); // 1) Submit email only -> a new account is "created" (mock 202) and AIO asks the user - // to verify their email. This is a normal (non-error) state transition: the page - // reloads into the awaiting-verification UI, which renders inside
and shows - // the dedicated re-submit button. - await containersPage.locator('input[name="desec_email"]').fill(email); - await containersPage.locator('input[name="desec_slug"]').first().fill(slug); - await containersPage.getByRole('button', { name: 'Register free domain via deSEC' }).click(); - await expect(containersPage.getByRole('main')).toContainText('check your inbox', { timeout: 30 * 1000 }); + // to verify their email. This is a normal (non-error) state transition: the iframe + // reloads into the awaiting-verification step inside the modal. + await frame.locator('input[name="desec_email"]').fill(email); + await frame.locator('input[name="desec_slug"]').first().fill(slug); + await frame.getByRole('button', { name: 'Register free domain via deSEC' }).click(); + await expect(frame.getByText('check your inbox')).toBeVisible({ timeout: 30 * 1000 }); await expect( - containersPage.getByRole('button', { name: 'I have verified my email – register domain' }), + frame.getByRole('button', { name: 'I have verified my email – register domain' }), ).toBeVisible(); // 2) Re-submit BEFORE verifying -> login still fails (mock 403) -> friendly hint shown. - await containersPage.getByRole('button', { name: 'I have verified my email – register domain' }).click(); - // Same as above: the failure hint is a transient toast, not
content. - await expect(containersPage.locator('.toast.error')).toContainText('Could not log in to deSEC', { timeout: 8 * 1000 }); + // The error is a transient toast rendered inside the iframe. + await frame.getByRole('button', { name: 'I have verified my email – register domain' }).click(); + await expect(frame.locator('.toast.error')).toContainText('Could not log in to deSEC', { timeout: 8 * 1000 }); // 3) Simulate the user clicking the verification link in their email. const verifyResponse = await fetch(`${DESEC_MOCK_URL}/__control/verify`, { method: 'POST' }); expect(verifyResponse.status).toBe(200); - // 4) Re-submit after verification -> login succeeds, domain is registered, the deSEC - // section disappears and the container-start UI appears. - await containersPage.getByRole('button', { name: 'I have verified my email – register domain' }).click(); + // 4) Re-submit after verification -> login succeeds, the domain is registered and the + // modal view reloads the parent page, where the deSEC entry point is gone and the + // container-start UI appears. + await frame.getByRole('button', { name: 'I have verified my email – register domain' }).click(); await expect(containersPage.getByRole('button', { name: 'Download and start containers' })).toBeVisible({ timeout: 60 * 1000 }); await expect( containersPage.getByText("Don't have a domain? Get a free one from deSEC"), diff --git a/tests/QA/002-new-instance.md b/tests/QA/002-new-instance.md index f8210895..57839732 100644 --- a/tests/QA/002-new-instance.md +++ b/tests/QA/002-new-instance.md @@ -10,10 +10,13 @@ For the below to work, it is important that you have a domain that you point ont - [ ] Entering `nextcloud.com` should report that the domain does not point to this server - [ ] Entering the domain that does point to your server e.g. `yourdomain.com` should finally redirect you to the next screen (if you did not configure your domain yet or did not open port 443, it should report that to you) - [ ] Below the domain input box there should be a collapsed `
` element labelled **"Don't have a domain? Get a free one from deSEC"** - - [ ] Expanding it should show a form with fields for email address and an optional subdomain slug, plus a **Register free domain via deSEC** submit button - - [ ] Submitting with an empty email should show an error asking for a valid email address - - [ ] Submitting with an invalid slug (e.g. `-bad-`) should show a validation error about the allowed slug format - - [ ] Submitting with a valid email and no slug should register a random `*.dedyn.io` domain, set it as the Nextcloud domain and redirect to the next screen + - [ ] Expanding it should show a short explanation and a **Register free domain via deSEC** button + - [ ] Clicking the button should open a modal window with the registration form (email address and an optional subdomain slug) without reloading the page + - [ ] Submitting with an empty email should show an error asking for a valid email address (inside the modal) + - [ ] Submitting with an invalid slug (e.g. `-bad-`) should show a validation error about the allowed slug format (inside the modal) + - [ ] Submitting with a valid email should step the modal through the register → verify email → register domain flow without closing it; you can adjust the details and re-submit between steps + - [ ] Closing the modal (via the × button, clicking the backdrop, or pressing Escape) and re-opening it should resume the registration where you left off + - [ ] Once the domain is fully registered the modal should close on its own and the whole page should reload with the new `*.dedyn.io` domain set as the Nextcloud domain - [ ] After successful registration, the `caddy` and `dnsmasq` community containers should be listed as enabled in the Community Containers section - [ ] After successful registration, there should be a **deSEC account credentials** section in the AIO interface that shows the registered email and allows revealing the password - [ ] Now you should see a button `Start containers` and an explanation which points out that clicking on the button will start the containers and that this can take a long time. From 13c762c720a3174cd9419119805534577cfccc73 Mon Sep 17 00:00:00 2001 From: "Simon L." Date: Mon, 22 Jun 2026 13:46:26 +0200 Subject: [PATCH 08/21] fix(desec): style the email field and stack the modal form inputs The email input was unstyled because input[type="email"] was missing from the text/password input rules, and the deSEC form's inline-block inputs flowed side by side. Add the email type to the input styling rules and lay the deSEC modal form out as a vertical stack with full-width inputs. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Simon L. --- php/public/style.css | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/php/public/style.css b/php/public/style.css index df817210..81ce3592 100644 --- a/php/public/style.css +++ b/php/public/style.css @@ -272,6 +272,7 @@ form { input[type="text"], input[type="password"], +input[type="email"], select { padding-left: 8px; padding-right: 8px; @@ -285,6 +286,7 @@ select { input[type="text"]:hover, input[type="password"]:hover, +input[type="email"]:hover, select:hover { border: var(--border-hover) solid var(--color-main-border-hover); } @@ -297,6 +299,7 @@ textarea { input[type="text"]:focus, input[type="password"]:focus, +input[type="email"]:focus, textarea:focus, select:focus { border: 1px solid var(--color-main-border); @@ -550,6 +553,21 @@ body.modal-open { padding: 1.5rem; } +/* Stack the deSEC form controls, each on its own line, instead of letting the + inline-block inputs flow side by side. */ +.desec-modal-body form.xhr { + display: flex; + flex-direction: column; + align-items: flex-start; +} + +.desec-modal-body form.xhr input[type="text"], +.desec-modal-body form.xhr input[type="email"], +.desec-modal-body form.xhr input[type="password"] { + width: 100%; + box-sizing: border-box; +} + .overlay-iframe { padding: 1rem; font-family: monospace, system-ui, -apple-system, 'Segoe UI', Roboto, Oxygen-Sans, Cantarell, Ubuntu, 'Helvetica Neue', 'Noto Sans', 'Liberation Sans', Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; From d2fbb711d2c70e6aa042ce7d5350c268099d8a5c Mon Sep 17 00:00:00 2001 From: "Simon L." Date: Mon, 22 Jun 2026 13:58:36 +0200 Subject: [PATCH 09/21] feat(desec): surface domain notice at top, drop in-flow credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hide the "Your deSEC login credentials" block during the in-flight register/verify steps — the credentials are still shown in the dedicated "deSEC account credentials" section once the domain is registered. Extract the deSEC domain info/dnsmasq-caution notice into a reusable include and also show it at the top of the initial-install flow, directly above the "download and start containers" explanation, while keeping it in the Community Containers section where it already appeared. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Simon L. --- php/templates/containers.twig | 3 +++ php/templates/includes/community-containers.twig | 3 +-- php/templates/includes/desec-domain-notice.twig | 2 ++ php/templates/includes/desec-register.twig | 2 -- 4 files changed, 6 insertions(+), 4 deletions(-) create mode 100644 php/templates/includes/desec-domain-notice.twig diff --git a/php/templates/containers.twig b/php/templates/containers.twig index 1f492499..3e4401c0 100644 --- a/php/templates/containers.twig +++ b/php/templates/containers.twig @@ -350,6 +350,9 @@

Restore or Backup currently running. Cannot start the containers until Restore or Backup is complete.

{% else %} {% if was_start_button_clicked == false %} + {% if is_desec_domain == true %} + {% include 'includes/desec-domain-notice.twig' %} + {% endif %}

Clicking on the button below will download all docker containers and start them. This can take a long time depending on your internet connection. Since the overall size is a few GB, this can take around 5-10 min or more. Please be patient!

{% endif %} {% if is_mastercontainer_update_available == true %} diff --git a/php/templates/includes/community-containers.twig b/php/templates/includes/community-containers.twig index 2e67d010..2034b950 100644 --- a/php/templates/includes/community-containers.twig +++ b/php/templates/includes/community-containers.twig @@ -2,8 +2,7 @@

In this section you can enable or disable optional Community Containers that are not included by default in the main installation. These containers are provided by the community and can be useful for various purposes and are automatically integrated in AIOs backup solution and update mechanisms.

⚠️ Caution: Community Containers are maintained by the community and not officially by Nextcloud. Some containers may not be compatible with your system, may not work as expected or may discontinue. Use them at your own risk. Please read the documentation for each container first before adding any as some are also incompatible between each other! Never add all of them at the same time!

{% if is_desec_domain == true %} -

ℹ️ Your Nextcloud domain ({{ domain }}) was registered via deSEC. The caddy community container has been automatically enabled as a reverse proxy and the dnsmasq container has been automatically enabled so that LAN devices can resolve your Nextcloud domain to the server's local IP address. Please read the dnsmasq documentation for the required router change.

-

⚠️ Caution: if this is a publicly reachable instance, you must disable dnsmasq again as dnsmasq must only run on home servers where port 53 is not publicly exposed.

+ {% include 'includes/desec-domain-notice.twig' %} {% endif %} {% if isAnyRunning == true %}

Please note: You can enable or disable the options below only when your containers are stopped.

diff --git a/php/templates/includes/desec-domain-notice.twig b/php/templates/includes/desec-domain-notice.twig new file mode 100644 index 00000000..42fa7e2b --- /dev/null +++ b/php/templates/includes/desec-domain-notice.twig @@ -0,0 +1,2 @@ +

ℹ️ Your Nextcloud domain ({{ domain }}) was registered via deSEC. The caddy community container has been automatically enabled as a reverse proxy and the dnsmasq container has been automatically enabled so that LAN devices can resolve your Nextcloud domain to the server's local IP address. Please read the dnsmasq documentation for the required router change.

+

⚠️ Caution: if this is a publicly reachable instance, you must disable dnsmasq again as dnsmasq must only run on home servers where port 53 is not publicly exposed.

diff --git a/php/templates/includes/desec-register.twig b/php/templates/includes/desec-register.twig index c52d7493..1a7ca899 100644 --- a/php/templates/includes/desec-register.twig +++ b/php/templates/includes/desec-register.twig @@ -3,7 +3,6 @@ {% if desec_awaiting_verification %}

⚠️ A deSEC account for {{ desec_email }} was requested but it could not be used yet. Please check your inbox: if deSEC sent you a verification email, click the link inside, then click the button below to finish setting up your domain.

If this email address already had a deSEC account, no new account was created and no verification email was sent. In that case, enter your existing deSEC password below and try again to log in with it.

-

Your deSEC login credentials (for desec.io): Email: {{ desec_email }}.

Reveal deSEC password{{ desec_password }}
. Please save these in a safe place.

@@ -13,7 +12,6 @@
{% elseif desec_account_registered %}

Your deSEC account ({{ desec_email }}) was registered successfully but the domain could not be registered. Please enter a desired subdomain slug (the part before .dedyn.io) and try again, or leave it blank for a random one.

-

Your deSEC login credentials (for desec.io): Email: {{ desec_email }}.

Reveal deSEC password{{ desec_password }}
. Please save these in a safe place.

From b4e4ff8fd7e3e4a9c6022659512c6da1a54c07a2 Mon Sep 17 00:00:00 2001 From: "Simon L." Date: Mon, 22 Jun 2026 14:12:35 +0200 Subject: [PATCH 10/21] fix a detail Signed-off-by: Simon L. --- php/templates/containers.twig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/php/templates/containers.twig b/php/templates/containers.twig index 3e4401c0..3213f4c9 100644 --- a/php/templates/containers.twig +++ b/php/templates/containers.twig @@ -404,7 +404,7 @@

deSEC account credentials

Your domain {{ domain }} is managed via deSEC. Below are your deSEC account credentials. You can use them to log in at desec.io to manage your domain directly.

Email: {{ desec_email }}

-

Password:

Reveal deSEC password{{ desec_password }}

+

Reveal deSEC password{{ desec_password }}

Please save these credentials in a safe place.

{% endif %} From 64d49c1b59f7c363a9fc64cb4d2bde478cdf0b48 Mon Sep 17 00:00:00 2001 From: "Simon L." Date: Mon, 22 Jun 2026 14:20:52 +0200 Subject: [PATCH 11/21] move desec futher up in the containers template Signed-off-by: Simon L. --- php/templates/containers.twig | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/php/templates/containers.twig b/php/templates/containers.twig index 3213f4c9..61d52189 100644 --- a/php/templates/containers.twig +++ b/php/templates/containers.twig @@ -110,6 +110,16 @@ {% if skip_domain_validation == true %}

Please note: The domain validation is disabled so any domain will be accepted here! Make sure you do not make a typo here as you will not be able to change it afterwards!

{% endif %} +
+ Don't have a domain? Get a free one from deSEC +

deSEC offers free dynamic DNS subdomains under dedyn.io. AIO can register an account and a subdomain for you automatically. Click the button below to start; the registration runs in a small window so you can adjust the details and complete the email verification without losing this page. Once it's done, this page reloads with your new domain configured.

+ {% if desec_awaiting_verification or desec_account_registered %} +

⚠️ A deSEC registration for {{ desec_email }} is already in progress but not finished yet. Click the button below to continue it.

+ + {% else %} + + {% endif %} +
@@ -132,16 +142,6 @@ {% endif %}

Hint: If the domain validation fails but you are completely sure that you've configured everything correctly, you may skip the domain validation by following this documentation.

-
- Don't have a domain? Get a free one from deSEC -

deSEC offers free dynamic DNS subdomains under dedyn.io. AIO can register an account and a subdomain for you automatically. Click the button below to start; the registration runs in a small window so you can adjust the details and complete the email verification without losing this page. Once it's done, this page reloads with your new domain configured.

- {% if desec_awaiting_verification or desec_account_registered %} -

⚠️ A deSEC registration for {{ desec_email }} is already in progress but not finished yet. Click the button below to continue it.

- - {% else %} - - {% endif %} -
-
- - - - {% if skip_domain_validation == true %} - - {% endif %} - -
+ {% if skip_domain_validation == false %}

Make sure that this server is reachable on port 443 (port 443/tcp is open/forwarded in your firewall/router and 443/udp as well if you want to enable http3) and that you've correctly set up the DNS config for the domain that you enter (set the A record to your public ipv4-address and if you need ipv6, set the AAAA record to your public ipv6-address. A CNAME record is, of course, also possible). You should see hints on what went wrong in the top right corner if your domain is not accepted.

From 9cfd5b41f687ed84a858b9cff62d3ee2a0655547 Mon Sep 17 00:00:00 2001 From: "Simon L." Date: Mon, 22 Jun 2026 15:19:42 +0200 Subject: [PATCH 16/21] fix(desec): reuse an existing slug the user already owns Logging in with an existing deSEC account and entering a slug you already own failed with "Domain limit exceeded": registerDomain() always tried to POST /domains/, which deSEC rejects (403 when the account is at its quota, 400/409 when the name is taken) even though the domain belongs to you. Now, for a user-specified slug, a 400/403/409 triggers an ownership check (GET /domains/{name}/); if the account already owns the domain it is reused and registration completes. Otherwise a clear message is shown (over-limit vs. taken by someone else). Random-slug registration is unchanged. Update the deSEC mock to mirror the real status codes (per-account ownership, domain limit -> 403, taken name -> 400, GET /domains/{name}/) and add a spec that exercises the over-limit ownership-recovery path. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Simon L. --- .github/workflows/playwright-on-push.yml | 2 + .../playwright-on-workflow-dispatch.yml | 2 + php/src/Desec/DesecManager.php | 62 ++++++++++++++++-- php/tests/desec-mock.mjs | 54 ++++++++++++++-- php/tests/tests/desec-existing-slug.spec.js | 63 +++++++++++++++++++ 5 files changed, 174 insertions(+), 9 deletions(-) create mode 100644 php/tests/tests/desec-existing-slug.spec.js diff --git a/.github/workflows/playwright-on-push.yml b/.github/workflows/playwright-on-push.yml index caed6978..6f35a045 100644 --- a/.github/workflows/playwright-on-push.yml +++ b/.github/workflows/playwright-on-push.yml @@ -186,6 +186,8 @@ jobs: if ! npx playwright test tests/desec-register.spec.js; then dump_logs; exit 1; fi reseed if ! npx playwright test tests/desec-existing.spec.js; then dump_logs; exit 1; fi + reseed + if ! npx playwright test tests/desec-existing-slug.spec.js; then dump_logs; exit 1; fi kill "$(cat "$GITHUB_WORKSPACE/desec-mock.pid")" || true - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.github/workflows/playwright-on-workflow-dispatch.yml b/.github/workflows/playwright-on-workflow-dispatch.yml index ae4b6b0c..3e84795d 100644 --- a/.github/workflows/playwright-on-workflow-dispatch.yml +++ b/.github/workflows/playwright-on-workflow-dispatch.yml @@ -141,6 +141,8 @@ jobs: if ! npx playwright test tests/desec-register.spec.js; then dump_logs; exit 1; fi reseed if ! npx playwright test tests/desec-existing.spec.js; then dump_logs; exit 1; fi + reseed + if ! npx playwright test tests/desec-existing-slug.spec.js; then dump_logs; exit 1; fi kill "$(cat "$GITHUB_WORKSPACE/desec-mock.pid")" || true - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/php/src/Desec/DesecManager.php b/php/src/Desec/DesecManager.php index ac36ec82..fe2dbd65 100644 --- a/php/src/Desec/DesecManager.php +++ b/php/src/Desec/DesecManager.php @@ -256,8 +256,16 @@ class DesecManager { * 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. * + * When a specific slug is requested and creation fails because the name is unavailable + * (HTTP 400/409) or the account's domain limit is reached (HTTP 403), the domain may + * already belong to this very account — a user re-using a slug they registered earlier. + * In that case we reuse the existing domain instead of failing, so an existing-account + * login can point AIO at a domain the user already owns. (deSEC returns 400 when a name + * conflicts with another user's zone and 403 once the per-account domain limit is hit; + * both look like a failure here even though the user owns the name.) + * * @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 + * @throws \Exception if the slug is taken by someone else, on network failure, or after exhausting random attempts */ public function registerDomain(string $token, string $slug): string { $random = $slug === ''; @@ -281,10 +289,25 @@ class DesecManager { return $domain; } - if ($code === 409) { - if (!$random) { - throw new \Exception('"' . $domain . '" is already taken. Please choose a different subdomain and try again.'); + // For a user-specified slug, the name may be unavailable (400/409) or the account's + // domain limit may be reached (403) precisely because the user already owns this + // domain. Reuse it rather than failing. + if (!$random && ($code === 400 || $code === 403 || $code === 409)) { + if ($this->ownsDomain($token, $domain)) { + return $domain; } + if ($code === 403) { + throw new \Exception( + 'Your deSEC account has reached its domain limit and "' . $domain . '" is not ' + . 'one of your existing domains. Remove an unused domain at desec.io, or contact ' + . 'deSEC support to raise the limit, then try again.' + ); + } + throw new \Exception('"' . $domain . '" is already taken. Please choose a different subdomain and try again.'); + } + + if ($code === 409) { + // Random slug collided with an existing name — try another. continue; } @@ -294,6 +317,37 @@ class DesecManager { throw new \Exception('Could not register a free dedyn.io domain after ' . self::MAX_SLUG_ATTEMPTS . ' attempts. Please try again.'); } + /** + * Checks whether the authenticated account already owns the given domain. + * + * Used to recover from a failed creation when the user is re-using a slug they + * registered earlier: GET /domains/{name}/ returns 200 only for a domain the + * token's account owns, 404 otherwise. + * + * @throws \Exception on network failure or an unexpected HTTP response + */ + private function ownsDomain(string $token, string $domain): bool { + try { + $res = $this->guzzleClient->get($this->configurationManager->desecApiBase . '/domains/' . $domain . '/', [ + 'headers' => ['Authorization' => 'Token ' . $token], + ]); + } catch (TransferException $e) { + throw new \Exception('Could not reach the deSEC API: ' . $e->getMessage()); + } + + $code = $res->getStatusCode(); + + if ($code === 200) { + return true; + } + + if ($code === 404) { + return false; + } + + throw new \Exception('Unexpected response from deSEC while checking domain ownership (HTTP ' . $code . '): ' . $res->getBody()->getContents()); + } + /** * Creates a wildcard CNAME rrset (*.domain → domain.) for a newly registered domain. * Errors are logged but do not abort the overall registration. diff --git a/php/tests/desec-mock.mjs b/php/tests/desec-mock.mjs index fb5267a4..b33690b3 100644 --- a/php/tests/desec-mock.mjs +++ b/php/tests/desec-mock.mjs @@ -15,7 +15,8 @@ // Routes: // POST /api/v1/auth/ -> 202 (account requested; verify email) // POST /api/v1/auth/login/ -> 403 until verified, then 200 + {token} -// POST /api/v1/domains/ -> 201 (new name) | 409 (name already taken) +// POST /api/v1/domains/ -> 201 (new) | 400 (taken by another zone) | 403 (account limit) +// GET /api/v1/domains/{name}/ -> 200 (owned by this account) | 404 (not owned) // POST /api/v1/domains/{name}/rrsets/ -> 201 (wildcard CNAME) // GET /update -> 200 "good" (dyndns update endpoint) // POST /__control/verify -> test hook: mark accounts email-verified @@ -28,10 +29,27 @@ const port = Number(process.argv[2] ?? process.env.DESEC_MOCK_PORT ?? 8090) // In-memory state. Reset between independent test scenarios via /__control/reset. const state = { accounts: new Map(), // email -> { password } - domains: new Set(), // fully-qualified domain names that have been created + domains: new Map(), // fully-qualified domain name -> owner email (mirrors deSEC: a name is globally unique) verified: false, // simulates whether the (most recent) account's email was verified } +// Per-account domain limit. The real deSEC service caps the number of domains an +// (unverified-payment) account may hold and answers 403 once it is reached; we model +// that so the "user already owns this slug but is over quota" recovery path is testable. +const DOMAIN_LIMIT = Number(process.env.DESEC_MOCK_DOMAIN_LIMIT ?? 15) + +// The mock issues tokens of the form `mock-token-` (see /auth/login/). +// Recover the owner email from the Authorization header so domain ops are per-account. +function emailFromToken(req) { + const auth = req.headers['authorization'] ?? '' + const token = auth.replace(/^Token\s+/i, '') + const hex = token.replace(/^mock-token-/, '') + for (const email of state.accounts.keys()) { + if (Buffer.from(email).toString('hex').slice(0, 16) === hex) return email + } + return undefined +} + function readBody(req) { return new Promise((resolve) => { let data = '' @@ -65,6 +83,13 @@ const server = createServer(async (req, res) => { state.verified = false return send(res, 200, { reset: true }) } + // Test hook: pre-assign a domain to an account, simulating one the user registered + // earlier (optionally filling the account up to its domain limit). + if (method === 'POST' && path === '/__control/seed-domain') { + const body = await readBody(req) + if (body.name && body.email) state.domains.set(String(body.name), String(body.email)) + return send(res, 200, { seeded: true }) + } // --- dyndns update endpoint (DesecManager::updateIpIfDesecDomain) --- if (method === 'GET' && path === '/update') { @@ -99,17 +124,36 @@ const server = createServer(async (req, res) => { }) } - // --- Domain creation: 201 for a new name, 409 if the same name already exists. --- + // --- Domain creation. Mirrors deSEC's status codes: --- + // 400 if the name is already registered (to this or another account); + // 403 once the account has reached its domain limit; + // 201 otherwise. if (method === 'POST' && path === '/api/v1/domains/') { const body = await readBody(req) const name = String(body.name ?? '') + const email = emailFromToken(req) if (state.domains.has(name)) { - return send(res, 409, { detail: 'This domain name is unavailable.' }) + return send(res, 400, { detail: 'This domain name is unavailable.' }) } - state.domains.add(name) + const owned = [...state.domains.values()].filter((owner) => owner === email).length + if (owned >= DOMAIN_LIMIT) { + return send(res, 403, { detail: 'Domain limit exceeded. Please contact support to create additional domains.' }) + } + state.domains.set(name, email) return send(res, 201, { name }) } + // --- Domain detail: 200 if the token's account owns the name, 404 otherwise. --- + const domainDetail = path.match(/^\/api\/v1\/domains\/([^/]+)\/$/) + if (method === 'GET' && domainDetail) { + const name = decodeURIComponent(domainDetail[1]) + const email = emailFromToken(req) + if (state.domains.get(name) === email && email !== undefined) { + return send(res, 200, { name }) + } + return send(res, 404, { detail: 'Not found.' }) + } + // --- RRset creation (wildcard CNAME): 201. --- if (method === 'POST' && /^\/api\/v1\/domains\/[^/]+\/rrsets\/$/.test(path)) { const body = await readBody(req) diff --git a/php/tests/tests/desec-existing-slug.spec.js b/php/tests/tests/desec-existing-slug.spec.js new file mode 100644 index 00000000..87a938db --- /dev/null +++ b/php/tests/tests/desec-existing-slug.spec.js @@ -0,0 +1,63 @@ +import { test, expect } from '@playwright/test'; +import { DESEC_MOCK_URL, logInToContainersPage } from './desec-helpers.js'; + +// Exercises re-using a slug the user already owns on an existing deSEC account. This is the +// case that previously failed: deSEC answers POST /domains/ with 403 "Domain limit exceeded" +// (the account is at its domain quota) even though the user owns the very slug they typed. +// DesecManager now recovers by checking GET /domains/{name}/ and reusing the owned domain. +// +// We seed the account up to the mock's domain limit, then pre-assign the target slug to that +// same account, so creation is rejected with 403 and the ownership check is what lets it through. +// +// Like the other domain-registering flows, this runs as its own CI step against freshly +// seeded deSEC + AIO state. + +test('deSEC reuses a slug the user already owns when over the domain limit', async ({ page: setupPage }) => { + test.setTimeout(5 * 60 * 1000); + + await fetch(`${DESEC_MOCK_URL}/__control/reset`, { method: 'POST' }); + const email = `owner-${Math.floor(Math.random() * 2147483647)}@example.com`; + const password = 'correct horse battery staple'; + await fetch(`${DESEC_MOCK_URL}/api/v1/auth/`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + await fetch(`${DESEC_MOCK_URL}/__control/verify`, { method: 'POST' }); + + const slug = `aio-owned-${Math.floor(Math.random() * 2147483647)}`; + const domain = `${slug}.dedyn.io`; + + // Fill the account up to the limit (15) with throwaway domains, then assign the target + // slug too — so a fresh POST /domains/ for it is rejected with 403, exactly as deSEC does. + for (let i = 0; i < 15; i++) { + await fetch(`${DESEC_MOCK_URL}/__control/seed-domain`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: `filler-${i}-${slug}.dedyn.io`, email }), + }); + } + await fetch(`${DESEC_MOCK_URL}/__control/seed-domain`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: domain, email }), + }); + + const containersPage = await logInToContainersPage(setupPage); + + await containersPage.getByText("Don't have a domain? Get a free one from deSEC").click(); + await containersPage.getByRole('button', { name: 'Register free domain via deSEC' }).click(); + + const frame = containersPage.frameLocator('#desec-frame'); + await frame.locator('input[name="desec_email"]').fill(email); + await frame.locator('input[name="desec_password"]').fill(password); + await frame.locator('input[name="desec_slug"]').first().fill(slug); + await frame.getByRole('button', { name: 'Register free domain via deSEC' }).click(); + + // Despite the 403 on creation, the domain the user already owns is reused and registration + // completes; the modal view then reloads the parent containers page. + await expect(containersPage.getByRole('button', { name: 'Download and start containers' })).toBeVisible({ timeout: 60 * 1000 }); + await expect( + containersPage.getByText("Don't have a domain? Get a free one from deSEC"), + ).toHaveCount(0); +}); From ebad4fd06f0f62c5096f94cbe31db3e4ba307374 Mon Sep 17 00:00:00 2001 From: "Simon L." Date: Mon, 22 Jun 2026 15:27:01 +0200 Subject: [PATCH 17/21] fix spelling Signed-off-by: Simon L. --- php/src/Desec/DesecManager.php | 4 ++-- php/tests/tests/desec-existing-slug.spec.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/php/src/Desec/DesecManager.php b/php/src/Desec/DesecManager.php index fe2dbd65..f6bb7531 100644 --- a/php/src/Desec/DesecManager.php +++ b/php/src/Desec/DesecManager.php @@ -258,7 +258,7 @@ class DesecManager { * * When a specific slug is requested and creation fails because the name is unavailable * (HTTP 400/409) or the account's domain limit is reached (HTTP 403), the domain may - * already belong to this very account — a user re-using a slug they registered earlier. + * already belong to this very account — a user reusing a slug they registered earlier. * In that case we reuse the existing domain instead of failing, so an existing-account * login can point AIO at a domain the user already owns. (deSEC returns 400 when a name * conflicts with another user's zone and 403 once the per-account domain limit is hit; @@ -320,7 +320,7 @@ class DesecManager { /** * Checks whether the authenticated account already owns the given domain. * - * Used to recover from a failed creation when the user is re-using a slug they + * Used to recover from a failed creation when the user is reusing a slug they * registered earlier: GET /domains/{name}/ returns 200 only for a domain the * token's account owns, 404 otherwise. * diff --git a/php/tests/tests/desec-existing-slug.spec.js b/php/tests/tests/desec-existing-slug.spec.js index 87a938db..01f1bfbe 100644 --- a/php/tests/tests/desec-existing-slug.spec.js +++ b/php/tests/tests/desec-existing-slug.spec.js @@ -1,7 +1,7 @@ import { test, expect } from '@playwright/test'; import { DESEC_MOCK_URL, logInToContainersPage } from './desec-helpers.js'; -// Exercises re-using a slug the user already owns on an existing deSEC account. This is the +// Exercises reusing a slug the user already owns on an existing deSEC account. This is the // case that previously failed: deSEC answers POST /domains/ with 403 "Domain limit exceeded" // (the account is at its domain quota) even though the user owns the very slug they typed. // DesecManager now recovers by checking GET /domains/{name}/ and reusing the owned domain. From 0d2ce61af326065532fa33823484a6e7a8974dcc Mon Sep 17 00:00:00 2001 From: "Simon L." Date: Mon, 22 Jun 2026 15:38:51 +0200 Subject: [PATCH 18/21] fix(desec): only reveal the deSEC password for AIO-created accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The containers page showed "Reveal deSEC password" for every deSEC domain, but when the user logs in with their own existing account AIO stores an empty password, so the reveal exposed nothing useful. Gate the reveal on a stored password: AIO-created accounts show the generated credentials as before; existing accounts now just say the domain is managed via that account and to log in at desec.io with your own password. Also clear the stored generated password in the awaiting-verification path when the user logs in with their own password (the email already had an account), so a non-empty stored password reliably means "AIO generated this account" — and report the account as not-new so no wildcard CNAME is created on a domain the user manages themselves. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Simon L. --- php/src/Desec/DesecManager.php | 12 ++++++++++-- php/templates/containers.twig | 16 ++++++++++++---- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/php/src/Desec/DesecManager.php b/php/src/Desec/DesecManager.php index f6bb7531..080e726b 100644 --- a/php/src/Desec/DesecManager.php +++ b/php/src/Desec/DesecManager.php @@ -99,12 +99,20 @@ class DesecManager { if ($this->configurationManager->isDesecAwaitingVerification()) { $storedEmail = $this->configurationManager->desecEmail; // Prefer a freshly entered password (e.g. the user changed it), else the generated one. - $loginPassword = $validatedPassword !== '' ? $validatedPassword : $this->configurationManager->desecPassword; + $userSuppliedPassword = $validatedPassword !== ''; + $loginPassword = $userSuppliedPassword ? $validatedPassword : $this->configurationManager->desecPassword; $token = $this->loginAfterVerification($storedEmail, $loginPassword); // Storing the token flips the state from "awaiting verification" to "account // registered" (see ConfigurationManager::isDesecAwaitingVerification()). $this->configurationManager->desecToken = $token; - return [$token, true]; + // If the user logged in with their own password (the email already had a deSEC + // account, so no AIO account was created), the previously generated password is + // wrong and must not be persisted or later revealed. Clear it so a stored, + // non-empty password reliably means "AIO generated this account". + if ($userSuppliedPassword) { + $this->configurationManager->desecPassword = ''; + } + return [$token, !$userSuppliedPassword]; } $validatedEmail = $this->validateEmail($email); diff --git a/php/templates/containers.twig b/php/templates/containers.twig index 7f27ccf6..6284ff14 100644 --- a/php/templates/containers.twig +++ b/php/templates/containers.twig @@ -404,10 +404,18 @@ {% if is_desec_domain %}

deSEC account credentials

-

Your domain {{ domain }} is managed via deSEC. Below are your deSEC account credentials. You can use them to log in at desec.io to manage your domain directly.

-

Email: {{ desec_email }}

-

Reveal deSEC password{{ desec_password }}

-

Please save these credentials in a safe place.

+ {% if desec_password %} + {# AIO created this account, so it generated and stored the password. + Show it so the user can log in at desec.io if they ever need to. #} +

Your domain {{ domain }} is managed via deSEC. Below are your deSEC account credentials. You can use them to log in at desec.io to manage your domain directly.

+

Email: {{ desec_email }}

+

Reveal deSEC password{{ desec_password }}

+

Please save these credentials in a safe place.

+ {% else %} + {# The user logged in with their own existing deSEC account; AIO never + stored their password, so there is nothing to reveal. #} +

Your domain {{ domain }} is managed via your existing deSEC account ({{ desec_email }}). Log in at desec.io with your own password to manage your domain directly.

+ {% endif %} {% endif %} {% if is_backup_section_enabled == false %} From 7e50969d0209f330f1df489c5ef6113926ecea34 Mon Sep 17 00:00:00 2001 From: "Simon L." Date: Mon, 22 Jun 2026 13:34:32 +0200 Subject: [PATCH 19/21] feat(desec): keep the typed slug across the registration flow A slug typed into the deSEC form was lost when the form re-rendered after a 201 redirect (e.g. on the awaiting-verification step), forcing the user to re-enter it. Persist the requested slug in the AIO config when register() runs and pre-fill the slug input from it on every step, then clear it once a domain is set. Stored in configuration.json rather than a URL parameter so it survives the multi-step flow without leaking into the iframe address. Signed-off-by: Simon L. Co-Authored-By: Claude Opus 4.8 (1M context) --- php/public/index.php | 1 + php/src/Data/ConfigurationManager.php | 10 ++++++++++ php/src/Desec/DesecManager.php | 6 ++++++ php/templates/includes/desec-register.twig | 6 +++--- 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/php/public/index.php b/php/public/index.php index 95f815fb..46c39786 100644 --- a/php/public/index.php +++ b/php/public/index.php @@ -205,6 +205,7 @@ $app->get('/desec', function (Request $request, Response $response, array $args) 'domain' => $configurationManager->domain, 'desec_email' => $configurationManager->desecEmail, 'desec_password' => $configurationManager->desecPassword, + 'desec_slug' => $configurationManager->desecSlug, 'is_desec_domain' => $configurationManager->isDesecDomain(), 'desec_account_registered' => $configurationManager->isDesecAccountRegistered(), 'desec_awaiting_verification' => $configurationManager->isDesecAwaitingVerification(), diff --git a/php/src/Data/ConfigurationManager.php b/php/src/Data/ConfigurationManager.php index 54a77055..aa757dd5 100644 --- a/php/src/Data/ConfigurationManager.php +++ b/php/src/Data/ConfigurationManager.php @@ -213,6 +213,16 @@ class ConfigurationManager set { $this->set('desec_email', $value); } } + /** + * The subdomain slug the user last requested. Persisted across the multi-step + * registration flow so the slug input can be pre-filled when the form re-renders + * (e.g. after email verification). Not a secret; cleared once a domain is set. + */ + public string $desecSlug { + get => $this->get('desec_slug', ''); + set { $this->set('desec_slug', $value); } + } + /** * Base URL of the deSEC API. Configurable via the 'desec_api_base' config key * (configuration.json) only — intentionally NOT an environment variable — so the diff --git a/php/src/Desec/DesecManager.php b/php/src/Desec/DesecManager.php index 080e726b..0e2fb8a0 100644 --- a/php/src/Desec/DesecManager.php +++ b/php/src/Desec/DesecManager.php @@ -45,6 +45,10 @@ class DesecManager { $validatedSlug = $this->validateSlug($slug); + // Persist the requested slug so the form can pre-fill it when it re-renders on the + // next step of the flow (e.g. after email verification). Cleared once a domain is set. + $this->configurationManager->desecSlug = $validatedSlug; + [$token, $isNewAccount] = $this->obtainToken($email, $password); // An empty token means a brand-new account was created but its email is not yet @@ -62,6 +66,8 @@ class DesecManager { $this->configurationManager->aioCommunityContainers = ["caddy", "dnsmasq"]; $this->configurationManager->setDomain($domain, true); + // Registration is complete; the stored slug is no longer needed. + $this->configurationManager->desecSlug = ''; $this->updateIpIfDesecDomain(); return true; diff --git a/php/templates/includes/desec-register.twig b/php/templates/includes/desec-register.twig index 1a7ca899..148efdab 100644 --- a/php/templates/includes/desec-register.twig +++ b/php/templates/includes/desec-register.twig @@ -7,7 +7,7 @@ - + {% elseif desec_account_registered %} @@ -15,7 +15,7 @@
- +
{% else %} @@ -26,7 +26,7 @@ - +

Note: By submitting this form you agree to the deSEC terms of service. The registered domain and your deSEC account credentials are stored in the AIO configuration. After registration, set your router's DHCP DNS server to this machine's local IP address so LAN devices resolve the domain locally (see the dnsmasq documentation). Alternatively adjust the hosts files on your clients so that they can reach the server using the local ip-address.

From 1fdda554210407aa5fe4f6c0ae5ab0f6da17690b Mon Sep 17 00:00:00 2001 From: "Simon L." Date: Tue, 23 Jun 2026 14:52:05 +0200 Subject: [PATCH 20/21] Apply suggestions from code review Co-authored-by: Pablo Zmdl <57864086+pabzm@users.noreply.github.com> Signed-off-by: Simon L. --- php/templates/containers.twig | 2 +- php/templates/includes/desec-domain-notice.twig | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/php/templates/containers.twig b/php/templates/containers.twig index 6284ff14..8537ecc3 100644 --- a/php/templates/containers.twig +++ b/php/templates/containers.twig @@ -122,7 +122,7 @@
Don't have a domain? Get a free one from deSEC -

deSEC offers free dynamic DNS subdomains under dedyn.io. AIO can register an account and a subdomain for you automatically. Click the button below to start; the registration runs in a small window so you can adjust the details and complete the email verification without losing this page. Once it's done, this page reloads with your new domain configured.

+

deSEC offers free dynamic DNS subdomains under dedyn.io. AIO can register an account and a subdomain for you automatically. Click the button below to start; the registration runs in a small window so you can adjust the details and complete the email verification without losing this page. Once it's done, this page reloads with your new domain configured. (To make your instance available publicly you additionally would have to make sure that port 443 is open in your server's firewall, and – if applicable – the port is also forwarded by your router to your server. See below for more details on port forwarding.)

{% if desec_awaiting_verification or desec_account_registered %}

⚠️ A deSEC registration for {{ desec_email }} is already in progress but not finished yet. Click the button below to continue it.

diff --git a/php/templates/includes/desec-domain-notice.twig b/php/templates/includes/desec-domain-notice.twig index 67c3e008..4735c0d0 100644 --- a/php/templates/includes/desec-domain-notice.twig +++ b/php/templates/includes/desec-domain-notice.twig @@ -1,3 +1,3 @@

ℹ️ Your Nextcloud domain ({{ domain }}) was registered via deSEC. The caddy community container has been automatically enabled as a reverse proxy and the dnsmasq container has been automatically enabled so that LAN devices can resolve your Nextcloud domain to the server's local IP address. Please read the dnsmasq documentation for the required router change.

-

⚠️ Caution: if this is a publicly reachable instance, you must disable dnsmasq again as dnsmasq must only run on home servers where port 53 is not publicly exposed.

-

⚠️ Note: dnsmasq's automatic local IP address detection only works on a native Linux host. On Docker Desktop (Windows/macOS) it cannot determine the machine's real LAN IP, so local DNS resolution will not work there.

+

ℹ️ Note: dnsmasq's automatic local IP address detection only works on a native Linux host. On Docker Desktop (Windows/macOS) it cannot determine the machine's real LAN IP, so local DNS resolution will not work there. To work around the problem, you can set up a local dns server externally or use the pi-hole community container and point the domain to the local ip-address of the server.

+

⚠️ Caution: Make sure that your server's port 53 is *not* reachable from the internet! E.g. block it in your firewall, or (if you don't need local network access to your instance because it is reachable publicly) disable the container by scrolling to the very bottom of this page and expanding the community containers list and unchecking dnsmasq.

From e0c208da19ba6b8c305944c9b70ed3eda2a70d63 Mon Sep 17 00:00:00 2001 From: "Simon L." Date: Tue, 23 Jun 2026 14:53:54 +0200 Subject: [PATCH 21/21] Apply suggestion from @szaimen Signed-off-by: Simon L. --- php/templates/includes/desec-domain-notice.twig | 1 + 1 file changed, 1 insertion(+) diff --git a/php/templates/includes/desec-domain-notice.twig b/php/templates/includes/desec-domain-notice.twig index 4735c0d0..54db4638 100644 --- a/php/templates/includes/desec-domain-notice.twig +++ b/php/templates/includes/desec-domain-notice.twig @@ -1,3 +1,4 @@

ℹ️ Your Nextcloud domain ({{ domain }}) was registered via deSEC. The caddy community container has been automatically enabled as a reverse proxy and the dnsmasq container has been automatically enabled so that LAN devices can resolve your Nextcloud domain to the server's local IP address. Please read the dnsmasq documentation for the required router change.

ℹ️ Note: dnsmasq's automatic local IP address detection only works on a native Linux host. On Docker Desktop (Windows/macOS) it cannot determine the machine's real LAN IP, so local DNS resolution will not work there. To work around the problem, you can set up a local dns server externally or use the pi-hole community container and point the domain to the local ip-address of the server.

⚠️ Caution: Make sure that your server's port 53 is *not* reachable from the internet! E.g. block it in your firewall, or (if you don't need local network access to your instance because it is reachable publicly) disable the container by scrolling to the very bottom of this page and expanding the community containers list and unchecking dnsmasq.

+

ℹ️ Note: if you want to make the instance publicly reachable, you need to forward port 443 udp and tcp in your router to this server.