diff --git a/.github/workflows/playwright-on-push.yml b/.github/workflows/playwright-on-push.yml index caed6978..6f35a045 100644 --- a/.github/workflows/playwright-on-push.yml +++ b/.github/workflows/playwright-on-push.yml @@ -186,6 +186,8 @@ jobs: if ! npx playwright test tests/desec-register.spec.js; then dump_logs; exit 1; fi reseed if ! npx playwright test tests/desec-existing.spec.js; then dump_logs; exit 1; fi + reseed + if ! npx playwright test tests/desec-existing-slug.spec.js; then dump_logs; exit 1; fi kill "$(cat "$GITHUB_WORKSPACE/desec-mock.pid")" || true - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.github/workflows/playwright-on-workflow-dispatch.yml b/.github/workflows/playwright-on-workflow-dispatch.yml index ae4b6b0c..3e84795d 100644 --- a/.github/workflows/playwright-on-workflow-dispatch.yml +++ b/.github/workflows/playwright-on-workflow-dispatch.yml @@ -141,6 +141,8 @@ jobs: if ! npx playwright test tests/desec-register.spec.js; then dump_logs; exit 1; fi reseed if ! npx playwright test tests/desec-existing.spec.js; then dump_logs; exit 1; fi + reseed + if ! npx playwright test tests/desec-existing-slug.spec.js; then dump_logs; exit 1; fi kill "$(cat "$GITHUB_WORKSPACE/desec-mock.pid")" || true - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/php/src/Desec/DesecManager.php b/php/src/Desec/DesecManager.php index ac36ec82..fe2dbd65 100644 --- a/php/src/Desec/DesecManager.php +++ b/php/src/Desec/DesecManager.php @@ -256,8 +256,16 @@ class DesecManager { * Registers a dedyn.io domain for the authenticated account. * When $slug is empty a random 10-character slug is tried up to MAX_SLUG_ATTEMPTS times. * + * When a specific slug is requested and creation fails because the name is unavailable + * (HTTP 400/409) or the account's domain limit is reached (HTTP 403), the domain may + * already belong to this very account — a user re-using a slug they registered earlier. + * In that case we reuse the existing domain instead of failing, so an existing-account + * login can point AIO at a domain the user already owns. (deSEC returns 400 when a name + * conflicts with another user's zone and 403 once the per-account domain limit is hit; + * both look like a failure here even though the user owns the name.) + * * @return string the fully-qualified domain name that was registered - * @throws \Exception if the slug is taken, on network failure, or after exhausting random attempts + * @throws \Exception if the slug is taken by someone else, on network failure, or after exhausting random attempts */ public function registerDomain(string $token, string $slug): string { $random = $slug === ''; @@ -281,10 +289,25 @@ class DesecManager { return $domain; } - if ($code === 409) { - if (!$random) { - throw new \Exception('"' . $domain . '" is already taken. Please choose a different subdomain and try again.'); + // For a user-specified slug, the name may be unavailable (400/409) or the account's + // domain limit may be reached (403) precisely because the user already owns this + // domain. Reuse it rather than failing. + if (!$random && ($code === 400 || $code === 403 || $code === 409)) { + if ($this->ownsDomain($token, $domain)) { + return $domain; } + if ($code === 403) { + throw new \Exception( + 'Your deSEC account has reached its domain limit and "' . $domain . '" is not ' + . 'one of your existing domains. Remove an unused domain at desec.io, or contact ' + . 'deSEC support to raise the limit, then try again.' + ); + } + throw new \Exception('"' . $domain . '" is already taken. Please choose a different subdomain and try again.'); + } + + if ($code === 409) { + // Random slug collided with an existing name — try another. continue; } @@ -294,6 +317,37 @@ class DesecManager { throw new \Exception('Could not register a free dedyn.io domain after ' . self::MAX_SLUG_ATTEMPTS . ' attempts. Please try again.'); } + /** + * Checks whether the authenticated account already owns the given domain. + * + * Used to recover from a failed creation when the user is re-using a slug they + * registered earlier: GET /domains/{name}/ returns 200 only for a domain the + * token's account owns, 404 otherwise. + * + * @throws \Exception on network failure or an unexpected HTTP response + */ + private function ownsDomain(string $token, string $domain): bool { + try { + $res = $this->guzzleClient->get($this->configurationManager->desecApiBase . '/domains/' . $domain . '/', [ + 'headers' => ['Authorization' => 'Token ' . $token], + ]); + } catch (TransferException $e) { + throw new \Exception('Could not reach the deSEC API: ' . $e->getMessage()); + } + + $code = $res->getStatusCode(); + + if ($code === 200) { + return true; + } + + if ($code === 404) { + return false; + } + + throw new \Exception('Unexpected response from deSEC while checking domain ownership (HTTP ' . $code . '): ' . $res->getBody()->getContents()); + } + /** * Creates a wildcard CNAME rrset (*.domain → domain.) for a newly registered domain. * Errors are logged but do not abort the overall registration. diff --git a/php/tests/desec-mock.mjs b/php/tests/desec-mock.mjs index fb5267a4..b33690b3 100644 --- a/php/tests/desec-mock.mjs +++ b/php/tests/desec-mock.mjs @@ -15,7 +15,8 @@ // Routes: // POST /api/v1/auth/ -> 202 (account requested; verify email) // POST /api/v1/auth/login/ -> 403 until verified, then 200 + {token} -// POST /api/v1/domains/ -> 201 (new name) | 409 (name already taken) +// POST /api/v1/domains/ -> 201 (new) | 400 (taken by another zone) | 403 (account limit) +// GET /api/v1/domains/{name}/ -> 200 (owned by this account) | 404 (not owned) // POST /api/v1/domains/{name}/rrsets/ -> 201 (wildcard CNAME) // GET /update -> 200 "good" (dyndns update endpoint) // POST /__control/verify -> test hook: mark accounts email-verified @@ -28,10 +29,27 @@ const port = Number(process.argv[2] ?? process.env.DESEC_MOCK_PORT ?? 8090) // In-memory state. Reset between independent test scenarios via /__control/reset. const state = { accounts: new Map(), // email -> { password } - domains: new Set(), // fully-qualified domain names that have been created + domains: new Map(), // fully-qualified domain name -> owner email (mirrors deSEC: a name is globally unique) verified: false, // simulates whether the (most recent) account's email was verified } +// Per-account domain limit. The real deSEC service caps the number of domains an +// (unverified-payment) account may hold and answers 403 once it is reached; we model +// that so the "user already owns this slug but is over quota" recovery path is testable. +const DOMAIN_LIMIT = Number(process.env.DESEC_MOCK_DOMAIN_LIMIT ?? 15) + +// The mock issues tokens of the form `mock-token-` (see /auth/login/). +// Recover the owner email from the Authorization header so domain ops are per-account. +function emailFromToken(req) { + const auth = req.headers['authorization'] ?? '' + const token = auth.replace(/^Token\s+/i, '') + const hex = token.replace(/^mock-token-/, '') + for (const email of state.accounts.keys()) { + if (Buffer.from(email).toString('hex').slice(0, 16) === hex) return email + } + return undefined +} + function readBody(req) { return new Promise((resolve) => { let data = '' @@ -65,6 +83,13 @@ const server = createServer(async (req, res) => { state.verified = false return send(res, 200, { reset: true }) } + // Test hook: pre-assign a domain to an account, simulating one the user registered + // earlier (optionally filling the account up to its domain limit). + if (method === 'POST' && path === '/__control/seed-domain') { + const body = await readBody(req) + if (body.name && body.email) state.domains.set(String(body.name), String(body.email)) + return send(res, 200, { seeded: true }) + } // --- dyndns update endpoint (DesecManager::updateIpIfDesecDomain) --- if (method === 'GET' && path === '/update') { @@ -99,17 +124,36 @@ const server = createServer(async (req, res) => { }) } - // --- Domain creation: 201 for a new name, 409 if the same name already exists. --- + // --- Domain creation. Mirrors deSEC's status codes: --- + // 400 if the name is already registered (to this or another account); + // 403 once the account has reached its domain limit; + // 201 otherwise. if (method === 'POST' && path === '/api/v1/domains/') { const body = await readBody(req) const name = String(body.name ?? '') + const email = emailFromToken(req) if (state.domains.has(name)) { - return send(res, 409, { detail: 'This domain name is unavailable.' }) + return send(res, 400, { detail: 'This domain name is unavailable.' }) } - state.domains.add(name) + const owned = [...state.domains.values()].filter((owner) => owner === email).length + if (owned >= DOMAIN_LIMIT) { + return send(res, 403, { detail: 'Domain limit exceeded. Please contact support to create additional domains.' }) + } + state.domains.set(name, email) return send(res, 201, { name }) } + // --- Domain detail: 200 if the token's account owns the name, 404 otherwise. --- + const domainDetail = path.match(/^\/api\/v1\/domains\/([^/]+)\/$/) + if (method === 'GET' && domainDetail) { + const name = decodeURIComponent(domainDetail[1]) + const email = emailFromToken(req) + if (state.domains.get(name) === email && email !== undefined) { + return send(res, 200, { name }) + } + return send(res, 404, { detail: 'Not found.' }) + } + // --- RRset creation (wildcard CNAME): 201. --- if (method === 'POST' && /^\/api\/v1\/domains\/[^/]+\/rrsets\/$/.test(path)) { const body = await readBody(req) diff --git a/php/tests/tests/desec-existing-slug.spec.js b/php/tests/tests/desec-existing-slug.spec.js new file mode 100644 index 00000000..87a938db --- /dev/null +++ b/php/tests/tests/desec-existing-slug.spec.js @@ -0,0 +1,63 @@ +import { test, expect } from '@playwright/test'; +import { DESEC_MOCK_URL, logInToContainersPage } from './desec-helpers.js'; + +// Exercises re-using a slug the user already owns on an existing deSEC account. This is the +// case that previously failed: deSEC answers POST /domains/ with 403 "Domain limit exceeded" +// (the account is at its domain quota) even though the user owns the very slug they typed. +// DesecManager now recovers by checking GET /domains/{name}/ and reusing the owned domain. +// +// We seed the account up to the mock's domain limit, then pre-assign the target slug to that +// same account, so creation is rejected with 403 and the ownership check is what lets it through. +// +// Like the other domain-registering flows, this runs as its own CI step against freshly +// seeded deSEC + AIO state. + +test('deSEC reuses a slug the user already owns when over the domain limit', async ({ page: setupPage }) => { + test.setTimeout(5 * 60 * 1000); + + await fetch(`${DESEC_MOCK_URL}/__control/reset`, { method: 'POST' }); + const email = `owner-${Math.floor(Math.random() * 2147483647)}@example.com`; + const password = 'correct horse battery staple'; + await fetch(`${DESEC_MOCK_URL}/api/v1/auth/`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + await fetch(`${DESEC_MOCK_URL}/__control/verify`, { method: 'POST' }); + + const slug = `aio-owned-${Math.floor(Math.random() * 2147483647)}`; + const domain = `${slug}.dedyn.io`; + + // Fill the account up to the limit (15) with throwaway domains, then assign the target + // slug too — so a fresh POST /domains/ for it is rejected with 403, exactly as deSEC does. + for (let i = 0; i < 15; i++) { + await fetch(`${DESEC_MOCK_URL}/__control/seed-domain`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: `filler-${i}-${slug}.dedyn.io`, email }), + }); + } + await fetch(`${DESEC_MOCK_URL}/__control/seed-domain`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: domain, email }), + }); + + const containersPage = await logInToContainersPage(setupPage); + + await containersPage.getByText("Don't have a domain? Get a free one from deSEC").click(); + await containersPage.getByRole('button', { name: 'Register free domain via deSEC' }).click(); + + const frame = containersPage.frameLocator('#desec-frame'); + await frame.locator('input[name="desec_email"]').fill(email); + await frame.locator('input[name="desec_password"]').fill(password); + await frame.locator('input[name="desec_slug"]').first().fill(slug); + await frame.getByRole('button', { name: 'Register free domain via deSEC' }).click(); + + // Despite the 403 on creation, the domain the user already owns is reused and registration + // completes; the modal view then reloads the parent containers page. + await expect(containersPage.getByRole('button', { name: 'Download and start containers' })).toBeVisible({ timeout: 60 * 1000 }); + await expect( + containersPage.getByText("Don't have a domain? Get a free one from deSEC"), + ).toHaveCount(0); +});