diff --git a/community-containers/ddclient/ddclient.json b/community-containers/ddclient/ddclient.json new file mode 100644 index 00000000..0ed4e554 --- /dev/null +++ b/community-containers/ddclient/ddclient.json @@ -0,0 +1,27 @@ +{ + "aio_services_v1": [ + { + "container_name": "nextcloud-aio-ddclient", + "display_name": "DDclient (deSEC DDNS)", + "documentation": "https://github.com/nextcloud/all-in-one/tree/main/community-containers/ddclient", + "image": "ghcr.io/linuxserver/ddclient", + "image_tag": "latest", + "restart": "unless-stopped", + "environment": [ + "TZ=%TIMEZONE%", + "NC_DOMAIN=%NC_DOMAIN%", + "DESEC_TOKEN=%DESEC_TOKEN%" + ], + "volumes": [ + { + "source": "nextcloud_aio_ddclient", + "destination": "/config", + "writeable": true + } + ], + "backup_volumes": [ + "nextcloud_aio_ddclient" + ] + } + ] +} diff --git a/community-containers/ddclient/readme.md b/community-containers/ddclient/readme.md new file mode 100644 index 00000000..21c187be --- /dev/null +++ b/community-containers/ddclient/readme.md @@ -0,0 +1,50 @@ +# DDclient community container + +This container runs [DDclient](https://ddclient.net/) pre-configured for use with [deSEC](https://desec.io/) dynamic DNS. + +When you register a free dedyn.io domain through the AIO interface, the `NC_DOMAIN` and `DESEC_TOKEN` environment variables are automatically set from the stored credentials. On first start the linuxserver/ddclient image creates an empty `/config/ddclient.conf` in the `nextcloud_aio_ddclient` volume; you need to populate that file once as described below. + +## One-time configuration + +After the container has started for the first time, run: + +```bash +docker exec -it nextcloud-aio-ddclient sh +``` + +Then create `/config/ddclient.conf` with the following content (replace the placeholders with the values printed in the container's environment): + +``` +daemon=300 +syslog=yes +ssl=yes + +use=web, web=https://checkipv4.dedyn.io/ + +protocol=dyndns2 +server=update.dedyn.io +login= +password= + +``` + +You can read the values from the running container: + +```bash +docker exec nextcloud-aio-ddclient printenv NC_DOMAIN +docker exec nextcloud-aio-ddclient printenv DESEC_TOKEN +``` + +Once the file is saved, restart the container: + +```bash +docker restart nextcloud-aio-ddclient +``` + +DDclient will now update the DNS record for your domain every 5 minutes. + +## Notes + +- The config volume (`nextcloud_aio_ddclient`) is included in AIO backups, so the configuration persists across updates and restores. +- A derivative image that auto-generates the config from `NC_DOMAIN` and `DESEC_TOKEN` without any manual step will be created in a dedicated repository in the future. +- For IPv6 support add a second `use` block pointing to `https://checkipv6.dedyn.io/` in the config file. See the [ddclient documentation](https://ddclient.net/protocols/dyndns2.html) for details. diff --git a/php/public/index.php b/php/public/index.php index 5d706c2d..2eea1ddb 100644 --- a/php/public/index.php +++ b/php/public/index.php @@ -109,6 +109,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) { @@ -180,6 +181,8 @@ $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, + 'is_desec_domain' => $configurationManager->isDesecDomain(), ]); })->setName('profile'); $app->get('/login', function (Request $request, Response $response, array $args) use ($container) { diff --git a/php/src/Controller/DesecController.php b/php/src/Controller/DesecController.php new file mode 100644 index 00000000..000b8d31 --- /dev/null +++ b/php/src/Controller/DesecController.php @@ -0,0 +1,173 @@ +guzzleClient = new Client([ + 'timeout' => 15, + 'connect_timeout' => 10, + 'http_errors' => false, + ]); + } + + public function Register(Request $request, Response $response, array $args): Response { + // Only allow registration when no domain is configured yet + if ($this->configurationManager->domain !== '') { + $response->getBody()->write('A domain is already configured. Reset the AIO instance first to register a new domain.'); + return $response->withStatus(422); + } + + $email = trim((string)($request->getParsedBody()['desec_email'] ?? '')); + if ($email === '' || filter_var($email, FILTER_VALIDATE_EMAIL) === false) { + $response->getBody()->write('Please provide a valid email address.'); + return $response->withStatus(422); + } + + try { + // Register an account at deSEC and obtain an API token + $password = bin2hex(random_bytes(24)); + $token = $this->registerDesecAccount($email, $password); + + // Register a free dedyn.io subdomain + $domain = $this->registerDesecDomain($token); + + // Persist the credentials and auto-enable the companion community containers + $this->configurationManager->startTransaction(); + $this->configurationManager->setDesecToken($token); + $this->configurationManager->desecEmail = $email; + $enabled = array_values(array_filter( + $this->configurationManager->aioCommunityContainers, + fn(string $cc): bool => $cc !== '', + )); + foreach (['caddy', 'ddclient'] as $cc) { + if (!in_array($cc, $enabled, true)) { + $enabled[] = $cc; + } + } + $this->configurationManager->aioCommunityContainers = $enabled; + $this->configurationManager->commitTransaction(); + + // Set the domain; skip the reachability validation because the domain was just + // created and DNS propagation may not have completed yet. + $this->configurationManager->setDomain($domain, true); + + return $response->withStatus(201)->withHeader('Location', '.'); + } catch (InvalidSettingConfigurationException $ex) { + $response->getBody()->write($ex->getMessage()); + return $response->withStatus(422); + } catch (\Exception $ex) { + $response->getBody()->write($ex->getMessage()); + return $response->withStatus(422); + } + } + + /** + * Creates a new deSEC account and returns the API token from the response. + * + * @throws \Exception on network failure or an unexpected API response + */ + private function registerDesecAccount(string $email, string $password): string { + try { + $res = $this->guzzleClient->post(self::DESEC_API_BASE . '/auth/', [ + 'json' => ['email' => $email, 'password' => $password], + ]); + } catch (TransferException $e) { + throw new \Exception('Could not reach the deSEC API: ' . $e->getMessage()); + } + + $httpCode = $res->getStatusCode(); + $body = $res->getBody()->getContents(); + + if ($httpCode === 400) { + $data = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + if (is_array($data) && isset($data['email'])) { + throw new \Exception( + 'This email address is already registered at deSEC. ' + . 'Please log in at https://desec.io to retrieve your token and configure ddclient manually.', + ); + } + throw new \Exception('Registration at deSEC failed (HTTP 400): ' . $body); + } + + if ($httpCode !== 201) { + throw new \Exception( + 'Unexpected response from deSEC during account registration ' + . '(HTTP ' . $httpCode . '): ' . $body, + ); + } + + $data = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($data) || !isset($data['token']['token']) || !is_string($data['token']['token'])) { + throw new \Exception( + 'Could not extract the API token from the deSEC response. Please try again.', + ); + } + + return $data['token']['token']; + } + + /** + * Registers a new dedyn.io subdomain and returns its full name. + * Retries with a different random slug on name conflicts. + * + * @throws \Exception when all attempts fail or a network/API error occurs + */ + private function registerDesecDomain(string $token): string { + $lastError = ''; + + for ($attempt = 0; $attempt < self::MAX_SLUG_ATTEMPTS; $attempt++) { + $slug = bin2hex(random_bytes(self::SLUG_BYTES)); + $domain = $slug . self::DEDYN_SUFFIX; + + try { + $res = $this->guzzleClient->post(self::DESEC_API_BASE . '/domains/', [ + 'headers' => ['Authorization' => 'Token ' . $token], + 'json' => ['name' => $domain], + ]); + } catch (TransferException $e) { + throw new \Exception('Could not reach the deSEC API: ' . $e->getMessage()); + } + + $httpCode = $res->getStatusCode(); + + if ($httpCode === 201) { + return $domain; + } + + if ($httpCode === 409) { + // Slug already taken — try another one + $lastError = '"' . $domain . '" is already taken'; + continue; + } + + $body = $res->getBody()->getContents(); + throw new \Exception( + 'Unexpected response from deSEC during domain registration ' + . '(HTTP ' . $httpCode . '): ' . $body, + ); + } + + throw new \Exception( + 'Could not register a free dedyn.io domain after ' . self::MAX_SLUG_ATTEMPTS . ' attempts' + . ($lastError !== '' ? ' (' . $lastError . ')' : '') . '. Please try again.', + ); + } +} diff --git a/php/src/Data/ConfigurationManager.php b/php/src/Data/ConfigurationManager.php index b226a139..72391260 100644 --- a/php/src/Data/ConfigurationManager.php +++ b/php/src/Data/ConfigurationManager.php @@ -198,6 +198,36 @@ class ConfigurationManager set { $this->set('turn_domain', $value); } } + public string $desecEmail { + get => $this->get('desec_email', ''); + set { $this->set('desec_email', $value); } + } + + /** + * Stores a deSEC API token in the secrets store. + * Unlike randomly-generated secrets, this token is obtained from the deSEC REST API and + * must be set explicitly; it is never auto-generated. + */ + public function setDesecToken(string $token): void { + $secrets = $this->get('secrets', []); + $secrets['DESEC_TOKEN'] = $token; + $this->set('secrets', $secrets); + } + + public function getDesecToken(): string { + $secrets = $this->get('secrets', []); + return isset($secrets['DESEC_TOKEN']) && is_string($secrets['DESEC_TOKEN']) + ? $secrets['DESEC_TOKEN'] + : ''; + } + + /** + * Returns true when the configured domain is a deSEC dedyn.io subdomain and a token is stored. + */ + public function isDesecDomain(): bool { + return str_ends_with($this->domain, '.dedyn.io') && $this->getDesecToken() !== ''; + } + public string $apachePort { get => $this->getEnvironmentalVariableOrConfig('APACHE_PORT', 'apache_port', '443'); set { $this->set('apache_port', $value); } @@ -1109,6 +1139,7 @@ class ConfigurationManager 'CADDY_IP_ADDRESS' => in_array('caddy', $this->aioCommunityContainers, true) ? gethostbyname('nextcloud-aio-caddy') : '', 'WHITEBOARD_ENABLED' => $this->isWhiteboardEnabled ? 'yes' : '', 'AIO_VERSION' => $this->getAioVersion(), + 'DESEC_TOKEN' => $this->getDesecToken(), default => $this->getRegisteredSecret($placeholder), }; } diff --git a/php/templates/containers.twig b/php/templates/containers.twig index adfe3161..c1d0434d 100644 --- a/php/templates/containers.twig +++ b/php/templates/containers.twig @@ -132,6 +132,19 @@ {% 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. The ddclient and caddy community containers will be enabled so that your IP address is kept up to date and your traffic is routed through a reverse proxy.

+

Requirements: Your server must be reachable from the internet (a public IP address is needed). Port 80 and 443 must be open/forwarded in your firewall/router.

+

Please enter your email address. A deSEC account and a random subdomain.dedyn.io domain will be created for you.

+
+ + + + +
+

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, finish the setup by configuring the ddclient container as described in its documentation.

+
{% endif %}

Restore former AIO instance from backup

diff --git a/php/templates/includes/community-containers.twig b/php/templates/includes/community-containers.twig index da1dd26d..b6463c14 100644 --- a/php/templates/includes/community-containers.twig +++ b/php/templates/includes/community-containers.twig @@ -1,6 +1,9 @@

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 and ddclient community containers have been automatically enabled. Please follow the ddclient documentation to finish configuring DNS updates for your domain.

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

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

{% else %}