mirror of
https://github.com/nextcloud/all-in-one.git
synced 2026-07-21 21:52:53 +00:00
add desec (#7995)
## Summary - Integrates Desec into the AIO interface which simplifies the domain registration and domain setup. - You can either create a new account directly or use an existing account. - You have a button that you can open where the process will be done. After the process is done, the whole page reloads and you can continue with the setup. - Caddy and dnsmasq community containers will be activated during the process which will take over the cert generation and enable split-brain dns. - Community containers will work automatically for example Vaultwarden as the subdomains of the desec domain point automatically via a wildcard dns entry towards the main desec domain. - Playwright tests are added to ensure that the system works as expected in the future - the desec api is mocked via a small node js based file. - the qa and other docs were updated to mention desec
This commit is contained in:
@@ -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
|
||||
@@ -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,68 @@ 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
|
||||
# The deSEC registration entry point renders regardless of SKIP_DOMAIN_VALIDATION,
|
||||
# and the deSEC flow sets the domain with validation skipped internally, so no
|
||||
# SKIP_DOMAIN_VALIDATION override is needed here.
|
||||
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 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
|
||||
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
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
|
||||
@@ -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,65 @@ 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
|
||||
# The deSEC registration entry point renders regardless of SKIP_DOMAIN_VALIDATION,
|
||||
# and the deSEC flow sets the domain with validation skipped internally, so no
|
||||
# SKIP_DOMAIN_VALIDATION override is needed here.
|
||||
# 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 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
|
||||
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
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -11,7 +11,7 @@ This container is automatically enabled when you register a deSEC domain through
|
||||
## How it works
|
||||
|
||||
On startup the container:
|
||||
1. Detects the server's primary LAN IP address automatically.
|
||||
1. Detects the server's primary LAN IP address automatically. ⚠️ This only works on a native Linux host. On Docker Desktop (Windows/macOS) the container runs inside Docker's Linux VM, so "host network mode" does not reach the real host LAN and the detected IP will not be the machine's actual LAN IP.
|
||||
2. Configures dnsmasq to resolve `NC_DOMAIN` (and all its subdomains) to that IP.
|
||||
3. Forwards all other DNS queries to the upstream nameservers from the host's `/etc/resolv.conf`.
|
||||
4. Listens only on the LAN interface to avoid conflicts with any system DNS resolver (e.g. `systemd-resolved`).
|
||||
@@ -26,6 +26,7 @@ Most routers expose this under **DHCP settings → Primary DNS** or **LAN → DN
|
||||
|
||||
## Notes
|
||||
|
||||
- The automatic LAN IP detection (and host-network port 53 binding) only works on a native **Linux** host. It does **not** work on **Docker Desktop** (Windows/macOS), where containers run inside a Linux VM that is isolated from the real host LAN.
|
||||
- This container should only be used in home networks as on public servers this might cause a security risk exposing a dns server publicly
|
||||
- This container is incompatible with the pi-hole community container as both need ports 53 to work correctly. Do not enable both at the same time!
|
||||
- The container runs in **host network mode** so it can bind directly to port 53 on the LAN interface. No additional port-forwarding is required.
|
||||
|
||||
+1
-1
@@ -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:
|
||||
|
||||
+11
-8
@@ -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:
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"use strict";
|
||||
|
||||
// Rendered into the deSEC modal view (desec.twig) once a deSEC domain has been fully
|
||||
// registered. The view lives inside an iframe opened by the containers page; the whole
|
||||
// process is now done, so reload the parent window to show the updated containers page.
|
||||
// When opened directly (not embedded), window.top === window, so this just reloads here.
|
||||
(function () {
|
||||
// Give the success message a brief moment so the user sees that it worked.
|
||||
setTimeout(function () {
|
||||
window.top.location.reload();
|
||||
}, 1500);
|
||||
})();
|
||||
@@ -0,0 +1,50 @@
|
||||
"use strict";
|
||||
|
||||
// Opens the deSEC registration flow (the /desec view) inside a modal iframe so the user can
|
||||
// run the multi-step register -> verify -> domain process without leaving the containers
|
||||
// page. The iframe re-navigates itself between steps; once a domain is registered, the
|
||||
// /desec view reloads the parent window via desec-done.js, so this script only has to deal
|
||||
// with opening and closing the modal.
|
||||
(function () {
|
||||
const modal = document.getElementById('desec-modal');
|
||||
const frame = document.getElementById('desec-frame');
|
||||
if (!modal || !frame) {
|
||||
return;
|
||||
}
|
||||
|
||||
function openModal() {
|
||||
// Load (or reload) the flow each time the modal is opened so it always reflects the
|
||||
// current registration state on the server.
|
||||
frame.src = 'desec';
|
||||
modal.hidden = false;
|
||||
document.body.classList.add('modal-open');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modal.hidden = true;
|
||||
document.body.classList.remove('modal-open');
|
||||
// Drop the iframe content so credentials are not left rendered in the background.
|
||||
frame.src = 'about:blank';
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-desec-open]').forEach((el) => {
|
||||
el.addEventListener('click', openModal);
|
||||
});
|
||||
document.querySelectorAll('[data-desec-close]').forEach((el) => {
|
||||
el.addEventListener('click', closeModal);
|
||||
});
|
||||
|
||||
// Close when clicking the dimmed backdrop (but not when clicking inside the dialog).
|
||||
modal.addEventListener('click', (event) => {
|
||||
if (event.target === modal) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Close on Escape for keyboard users.
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape' && !modal.hidden) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -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,10 +184,35 @@ $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');
|
||||
|
||||
// Renders only the deSEC registration flow. The containers page opens this in a modal
|
||||
// iframe so the user can run the multi-step register -> verify -> domain process (adjusting
|
||||
// the inputs and re-submitting as needed) without reloading the whole page each time. Once a
|
||||
// deSEC domain is configured the view tells the parent window to reload (see desec-done.js).
|
||||
$app->get('/desec', function (Request $request, Response $response, array $args) use ($container) {
|
||||
$view = Twig::fromRequest($request);
|
||||
/** @var \AIO\Data\ConfigurationManager $configurationManager */
|
||||
$configurationManager = $container->get(\AIO\Data\ConfigurationManager::class);
|
||||
return $view->render($response, 'desec.twig', [
|
||||
'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(),
|
||||
// Do not cache the page as it shows credentials
|
||||
])->withHeader('Cache-Control', 'no-store');
|
||||
})->setName('desec');
|
||||
|
||||
$app->get('/login', function (Request $request, Response $response, array $args) use ($container) {
|
||||
$view = Twig::fromRequest($request);
|
||||
/** @var \AIO\Docker\DockerActionManager $dockerActionManager */
|
||||
|
||||
@@ -84,6 +84,7 @@ a:hover {
|
||||
}
|
||||
|
||||
a.button,
|
||||
button.button,
|
||||
input[type="submit"] {
|
||||
padding: 8px 16px;
|
||||
width: auto;
|
||||
@@ -100,11 +101,13 @@ input[type="submit"] {
|
||||
}
|
||||
|
||||
a.button:focus,
|
||||
button.button:focus,
|
||||
input[type="submit"]:focus {
|
||||
outline: 2px solid var(--color-main-border);
|
||||
}
|
||||
|
||||
a.button:hover,
|
||||
button.button:hover,
|
||||
input[type="submit"]:hover {
|
||||
background-color: var(--color-primary-element-hover);
|
||||
}
|
||||
@@ -269,6 +272,7 @@ form {
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
input[type="email"],
|
||||
select {
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
@@ -282,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);
|
||||
}
|
||||
@@ -294,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);
|
||||
@@ -490,6 +496,78 @@ input[type="checkbox"]:disabled:not(:checked) + label {
|
||||
background-color: var(--color-main-background);
|
||||
}
|
||||
|
||||
/* deSEC registration modal (containers page) */
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 3;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal .modal-content {
|
||||
position: relative;
|
||||
width: min(640px, 100%);
|
||||
max-height: calc(100vh - 4rem);
|
||||
background-color: var(--color-main-background);
|
||||
border-radius: var(--border-radius-large);
|
||||
border: solid thin rgb(192, 192, 192);
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.35);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal .modal-content iframe {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: min(640px, calc(100vh - 4rem));
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.modal .modal-close {
|
||||
position: absolute;
|
||||
top: 0.25rem;
|
||||
right: 0.5rem;
|
||||
z-index: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 1.8rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
body.modal-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* deSEC modal view body (desec.twig) */
|
||||
.desec-modal-body,
|
||||
.desec-modal-done {
|
||||
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';
|
||||
@@ -732,6 +810,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 {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace AIO\Controller;
|
||||
|
||||
use AIO\Desec\DesecManager;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
|
||||
readonly class DesecController {
|
||||
public function __construct(
|
||||
private DesecManager $desecManager,
|
||||
) {
|
||||
}
|
||||
|
||||
public function Register(Request $request, Response $response, array $args): Response {
|
||||
try {
|
||||
$email = (string)($request->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.
|
||||
// The form is submitted from the /desec modal view (an iframe), so redirect back to
|
||||
// that same view (201 + Location): the iframe re-renders the next step of the flow
|
||||
// (awaiting verification, or the fully-registered success page, which then reloads
|
||||
// the parent containers page). Re-rendering /desec — rather than the whole
|
||||
// containers page — is what keeps the multi-step flow inside the modal.
|
||||
$this->desecManager->register($email, $slug, $password);
|
||||
return $response->withStatus(201)->withHeader('Location', 'desec');
|
||||
} catch (\Exception $ex) {
|
||||
$response->getBody()->write($ex->getMessage());
|
||||
return $response->withStatus(422);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
// increase memory limit to 2GB
|
||||
ini_set('memory_limit', '2048M');
|
||||
|
||||
// Log whole log messages
|
||||
ini_set('log_errors_max_len', '0');
|
||||
|
||||
require __DIR__ . '/../../vendor/autoload.php';
|
||||
|
||||
$container = \AIO\DependencyInjection::GetContainer();
|
||||
|
||||
/** @var \AIO\Desec\DesecManager $desecManager */
|
||||
$desecManager = $container->get(\AIO\Desec\DesecManager::class);
|
||||
|
||||
$desecManager->updateIpIfDesecDomain();
|
||||
@@ -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,82 @@ class ConfigurationManager
|
||||
set { $this->set('turn_domain', $value); }
|
||||
}
|
||||
|
||||
public string $desecEmail {
|
||||
get => $this->get('desec_email', '');
|
||||
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
|
||||
* 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 +1197,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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace AIO\Desec;
|
||||
|
||||
use AIO\Data\ConfigurationManager;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\TransferException;
|
||||
|
||||
class DesecManager {
|
||||
private const int MAX_SLUG_ATTEMPTS = 5;
|
||||
private const int SLUG_BYTES = 5; // bin2hex → 10-char slug
|
||||
private const string SLUG_PATTERN = '/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/';
|
||||
|
||||
private Client $guzzleClient;
|
||||
|
||||
public function __construct(
|
||||
private readonly ConfigurationManager $configurationManager,
|
||||
) {
|
||||
$this->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);
|
||||
|
||||
// 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
|
||||
// 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);
|
||||
// Registration is complete; the stored slug is no longer needed.
|
||||
$this->configurationManager->desecSlug = '';
|
||||
$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.
|
||||
$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;
|
||||
// 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);
|
||||
|
||||
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.
|
||||
*
|
||||
* 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 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;
|
||||
* 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 by someone else, 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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the authenticated account already owns the given domain.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* @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.
|
||||
*/
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,11 +119,30 @@
|
||||
{% endif %}
|
||||
<input type="submit" value="Submit domain" />
|
||||
</form>
|
||||
|
||||
<details>
|
||||
<summary>Don't have a domain? Get a free one from deSEC</summary>
|
||||
<p><a target="_blank" href="https://desec.io">deSEC</a> offers free dynamic DNS subdomains under <strong>dedyn.io</strong>. 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.)</p>
|
||||
{% if desec_awaiting_verification or desec_account_registered %}
|
||||
<p>⚠️ A deSEC registration for <strong>{{ desec_email }}</strong> is already in progress but not finished yet. Click the button below to continue it.</p>
|
||||
<button type="button" class="button" data-desec-open>Continue deSEC registration</button>
|
||||
{% else %}
|
||||
<button type="button" class="button" data-desec-open>Register free domain via deSEC</button>
|
||||
{% endif %}
|
||||
</details>
|
||||
<div id="desec-modal" class="modal" role="dialog" aria-modal="true" aria-label="Register a free deSEC domain" hidden>
|
||||
<div class="modal-content">
|
||||
<button type="button" class="modal-close" data-desec-close aria-label="Close">×</button>
|
||||
<iframe id="desec-frame" title="Register a free deSEC domain"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
<script type="text/javascript" src="desec-modal.js"></script>
|
||||
|
||||
{% if skip_domain_validation == false %}
|
||||
<p>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.</p>
|
||||
<details>
|
||||
<summary>Click here for further hints</summary>
|
||||
<p>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 <a target="_blank" href="https://github.com/nextcloud/all-in-one/discussions/6817">Tailscale</a></p>
|
||||
<p>If you do not have a domain yet, the easiest option is to use the <strong>deSEC free domain</strong> registration below. You can also get a free domain from duckdns.org or others, or use <a target="_blank" href="https://github.com/nextcloud/all-in-one/discussions/6817">Tailscale</a>.</p>
|
||||
<p>If you have a dynamic public IP-address, you can use e.g. <a target="_blank" href="https://ddclient.net/">DDclient</a> with a compatible domain provider for DNS updates.</p>
|
||||
<p>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 <a target="_blank" href="https://github.com/nextcloud/all-in-one/blob/main/local-instance.md">this documentation</a>.</p>
|
||||
<p>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.</p>
|
||||
@@ -333,6 +352,9 @@
|
||||
<p>Restore or Backup currently running. Cannot start the containers until Restore or Backup is complete.</p>
|
||||
{% else %}
|
||||
{% if was_start_button_clicked == false %}
|
||||
{% if is_desec_domain == true %}
|
||||
{% include 'includes/desec-domain-notice.twig' %}
|
||||
{% endif %}
|
||||
<p>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!</p>
|
||||
{% endif %}
|
||||
{% if is_mastercontainer_update_available == true %}
|
||||
@@ -380,6 +402,22 @@
|
||||
|
||||
{% if was_start_button_clicked == true %}
|
||||
|
||||
{% if is_desec_domain %}
|
||||
<h2>deSEC account credentials</h2>
|
||||
{% 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. #}
|
||||
<p>Your domain <strong>{{ domain }}</strong> is managed via <a target="_blank" href="https://desec.io">deSEC</a>. Below are your deSEC account credentials. You can use them to log in at <a target="_blank" href="https://desec.io">desec.io</a> to manage your domain directly.</p>
|
||||
<p>Email: <strong>{{ desec_email }}</strong></p>
|
||||
<p><details class="inline-details"><summary>Reveal deSEC password</summary><strong>{{ desec_password }}</strong></details></p>
|
||||
<p>Please save these credentials in a safe place.</p>
|
||||
{% else %}
|
||||
{# The user logged in with their own existing deSEC account; AIO never
|
||||
stored their password, so there is nothing to reveal. #}
|
||||
<p>Your domain <strong>{{ domain }}</strong> is managed via your existing <a target="_blank" href="https://desec.io">deSEC</a> account (<strong>{{ desec_email }}</strong>). Log in at <a target="_blank" href="https://desec.io">desec.io</a> with your own password to manage your domain directly.</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if is_backup_section_enabled == false %}
|
||||
<h2>Backup and restore</h2>
|
||||
<p>The backup section is disabled via environmental variable.</p>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Register a free deSEC domain</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<link rel="stylesheet" href="style.css?v13" media="all" />
|
||||
<link rel="icon" href="img/favicon.png">
|
||||
<script type="text/javascript" src="forms.js?v2"></script>
|
||||
<script type="text/javascript" src="click-handlers.js?v2"></script>
|
||||
<script type="text/javascript" src="apply-theme.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
{% 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. #}
|
||||
<p class="desec-modal-done"><span class="status success"></span> Your free domain <strong>{{ domain }}</strong> was registered successfully. Finishing up…</p>
|
||||
<script type="text/javascript" src="desec-done.js"></script>
|
||||
{% else %}
|
||||
<div class="desec-modal-body">
|
||||
{% include 'includes/desec-register.twig' %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="overlay">
|
||||
<div class="loader"></div>
|
||||
<iframe name="overlay-log" id="overlay-log"></iframe>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,6 +1,9 @@
|
||||
<h2>Community Containers</h2>
|
||||
<p>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.</p>
|
||||
<p><strong>⚠️ Caution: </strong>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!</p>
|
||||
{% if is_desec_domain == true %}
|
||||
{% include 'includes/desec-domain-notice.twig' %}
|
||||
{% endif %}
|
||||
{% if isAnyRunning == true %}
|
||||
<p><strong>Please note:</strong> You can enable or disable the options below only when your containers are stopped.</p>
|
||||
{% else %}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<p>ℹ️ Your Nextcloud domain (<strong>{{ domain }}</strong>) was registered via deSEC. The <strong>caddy</strong> community container has been automatically enabled as a reverse proxy and the <strong>dnsmasq</strong> container has been automatically enabled so that LAN devices can resolve your Nextcloud domain to the server's local IP address. Please <a target="_blank" href="https://github.com/nextcloud/all-in-one/tree/main/community-containers/dnsmasq"><strong>read the dnsmasq documentation</strong></a> for the required router change.</p>
|
||||
<p>ℹ️ 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.</p>
|
||||
<p>⚠️ 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.</p>
|
||||
<p> ℹ️ 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.</p>
|
||||
@@ -0,0 +1,33 @@
|
||||
<h2>Get a free domain from deSEC</h2>
|
||||
<p><a target="_blank" href="https://desec.io">deSEC</a> offers free dynamic DNS subdomains under <strong>dedyn.io</strong>. AIO can register an account and a subdomain for you automatically. The <strong>caddy</strong> community container will be enabled as a reverse proxy, the <strong>dnsmasq</strong> container will be enabled for local DNS resolution, and the mastercontainer will keep your DNS record up to date automatically.</p>
|
||||
{% if desec_awaiting_verification %}
|
||||
<p>⚠️ A deSEC account for <strong>{{ desec_email }}</strong> 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.</p>
|
||||
<p>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.</p>
|
||||
<form method="POST" action="api/desec/register" class="xhr">
|
||||
<input type="hidden" name="{{csrf.keys.name}}" value="{{csrf.name}}">
|
||||
<input type="hidden" name="{{csrf.keys.value}}" value="{{csrf.value}}">
|
||||
<input type="password" name="desec_password" placeholder="Existing deSEC password (only if you already had an account)" autocomplete="current-password" />
|
||||
<input type="text" name="desec_slug" value="{{ desec_slug|default('') }}" placeholder="my-nextcloud (optional)" pattern="[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?" title="Only lowercase letters, digits and hyphens (1–63 characters). No leading or trailing hyphen." />
|
||||
<input type="submit" value="I have verified my email – register domain" />
|
||||
</form>
|
||||
{% elseif desec_account_registered %}
|
||||
<p>Your deSEC account (<strong>{{ desec_email }}</strong>) was registered successfully but the domain could not be registered. Please enter a desired subdomain slug (the part before <code>.dedyn.io</code>) and try again, or leave it blank for a random one.</p>
|
||||
<form method="POST" action="api/desec/register" class="xhr">
|
||||
<input type="hidden" name="{{csrf.keys.name}}" value="{{csrf.name}}">
|
||||
<input type="hidden" name="{{csrf.keys.value}}" value="{{csrf.value}}">
|
||||
<input type="text" name="desec_slug" value="{{ desec_slug|default('') }}" placeholder="my-nextcloud (optional)" pattern="[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?" title="Only lowercase letters, digits and hyphens (1–63 characters). No leading or trailing hyphen." />
|
||||
<input type="submit" value="Register free domain via deSEC" />
|
||||
</form>
|
||||
{% else %}
|
||||
<p>Please enter your email address. You can also enter a desired subdomain slug (the part before <code>.dedyn.io</code>); leave it blank for a random one.</p>
|
||||
<p>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.</p>
|
||||
<form method="POST" action="api/desec/register" class="xhr">
|
||||
<input type="hidden" name="{{csrf.keys.name}}" value="{{csrf.name}}">
|
||||
<input type="hidden" name="{{csrf.keys.value}}" value="{{csrf.value}}">
|
||||
<input type="email" name="desec_email" placeholder="your@email.com" required />
|
||||
<input type="password" name="desec_password" placeholder="deSEC password (only if already registered)" autocomplete="current-password" />
|
||||
<input type="text" name="desec_slug" value="{{ desec_slug|default('') }}" placeholder="my-nextcloud (optional)" pattern="[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?" title="Only lowercase letters, digits and hyphens (1–63 characters). No leading or trailing hyphen." />
|
||||
<input type="submit" value="Register free domain via deSEC" />
|
||||
</form>
|
||||
<p><strong>Note:</strong> By submitting this form you agree to the <a target="_blank" href="https://desec.io/terms">deSEC terms of service</a>. 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 <a target="_blank" href="https://github.com/nextcloud/all-in-one/tree/main/community-containers/dnsmasq">dnsmasq documentation</a>). Alternatively adjust the hosts files on your clients so that they can reach the server using the local ip-address.</p>
|
||||
{% endif %}
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>AIO</title>
|
||||
<link rel="stylesheet" href="style.css?v12" media="all" />
|
||||
<link rel="stylesheet" href="style.css?v13" media="all" />
|
||||
<link rel="icon" href="img/favicon.png">
|
||||
<script type="text/javascript" src="forms.js?v2"></script>
|
||||
<script type="text/javascript" src="toggle-dark-mode.js?v2"></script>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<head>
|
||||
<title>AIO</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<link rel="stylesheet" href="style.css?v12">
|
||||
<link rel="stylesheet" href="style.css?v13">
|
||||
<link rel="stylesheet" href="logs.css?v1">
|
||||
<link rel="icon" href="img/favicon.png">
|
||||
<script src="apply-theme.js?v1"></script>
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
// 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) | 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
|
||||
// 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 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-<hex(email)[:16]>` (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 = ''
|
||||
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 })
|
||||
}
|
||||
// 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') {
|
||||
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. 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, 400, { detail: 'This domain name is unavailable.' })
|
||||
}
|
||||
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)
|
||||
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}`)
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
// Test helper: point the running mastercontainer's deSEC integration at a local mock by
|
||||
// writing the (config-only) desec_api_base / desec_update_url keys into configuration.json.
|
||||
// Run inside the mastercontainer via `docker exec` from the Playwright CI workflows; there
|
||||
// is no env override for these on purpose (see ConfigurationManager::$desecApiBase).
|
||||
//
|
||||
// Writing configuration.json makes Setup::CanBeInstalled() return false, so /setup no longer
|
||||
// renders the initial-password page and no master password is generated. To keep the deSEC
|
||||
// Playwright test able to log in, this helper also seeds a known master password (passed via
|
||||
// the AIO_TEST_PASSWORD env var) that the test then uses directly. The password is stored in
|
||||
// plaintext, which matches how AIO compares it (AuthManager::CheckCredentials uses
|
||||
// hash_equals against the plaintext config value).
|
||||
//
|
||||
// Re-running this helper also RESETS the deSEC-specific state (domain, desec_email and the
|
||||
// DESEC_TOKEN / DESEC_PASSWORD secrets) so that a deSEC flow which already registered a domain
|
||||
// in a previous test does not bleed into the next test (where the registration UI only renders
|
||||
// while no domain is set). The Playwright deSEC scenarios are therefore each run as a separate
|
||||
// CI step with a re-seed in between (see the Playwright workflows). Other config and secrets
|
||||
// are preserved.
|
||||
//
|
||||
// Usage: AIO_TEST_PASSWORD=... php seed-desec-mock-config.php <api_base> <update_url>
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$apiBase = $argv[1] ?? '';
|
||||
$updateUrl = $argv[2] ?? '';
|
||||
if ($apiBase === '' || $updateUrl === '') {
|
||||
fwrite(STDERR, "usage: php seed-desec-mock-config.php <api_base> <update_url>\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";
|
||||
@@ -0,0 +1,63 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { DESEC_MOCK_URL, logInToContainersPage } from './desec-helpers.js';
|
||||
|
||||
// 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.
|
||||
//
|
||||
// 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);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
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.
|
||||
//
|
||||
// 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).
|
||||
|
||||
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)}`;
|
||||
|
||||
// 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();
|
||||
|
||||
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"),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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's
|
||||
// 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.
|
||||
|
||||
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 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 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(
|
||||
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.
|
||||
// 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, 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"),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
|
||||
@@ -9,6 +9,16 @@ 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 `<details>` element labelled **"Don't have a domain? Get a free one from deSEC"**
|
||||
- [ ] 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.
|
||||
- [ ] 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
|
||||
|
||||
Reference in New Issue
Block a user