From 473738db9e507775cdc043b7841bba45160ecc0e Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 8 Oct 2026 12:32:36 +0200 Subject: [PATCH] feat(domains): Standard-Nameserver aus dem AutoDNS-Profil lesen (h3t) - parseProfileNameServers erkennt die Nameserver tolerant ([ASSUMED] Schluesselnamen) - createOrder liest das Profil serverseitig, Browser-Liste wird ignoriert - GET modules/domains/name-servers (Verwalten), Submit-Sperre bei weniger als zwei Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/api/src/domains/autodns-parse.spec.ts | 131 +++++++++++ apps/api/src/domains/autodns-parse.ts | 94 ++++++++ apps/api/src/domains/domain-name.ts | 4 + .../domains/domains-orders.service.spec.ts | 213 ++++++++++++++++-- .../api/src/domains/domains-orders.service.ts | 73 +++--- .../src/domains/domains-settings.service.ts | 5 +- .../src/domains/domains.controller.spec.ts | 5 + apps/api/src/domains/domains.controller.ts | 8 +- apps/api/src/domains/dto/domains-order.dto.ts | 23 +- .../module-manage-handlers.spec.ts | 1 + 10 files changed, 485 insertions(+), 72 deletions(-) diff --git a/apps/api/src/domains/autodns-parse.spec.ts b/apps/api/src/domains/autodns-parse.spec.ts index 6000c35..bb00fc8 100644 --- a/apps/api/src/domains/autodns-parse.spec.ts +++ b/apps/api/src/domains/autodns-parse.spec.ts @@ -6,6 +6,7 @@ import { parseDomains, parseJob, parseJobList, + parseProfileNameServers, } from './autodns-parse'; import { TtlCache } from './domains-cache'; @@ -282,3 +283,133 @@ describe('parseJobList', () => { expect(list[1]).toMatchObject({ id: '10', object: 'x.de', created: null }); }); }); + +describe('parseProfileNameServers (h3t, [ASSUMED] Schluesselnamen)', () => { + const profile = (...pairs: Array<[string, string]>) => [ + { profiles: pairs.map(([key, value]) => ({ key, value })) }, + ]; + + it('ordnet nach der Nummer im Schluessel und ignoriert fremde Eintraege', () => { + const result = parseProfileNameServers( + profile( + ['ns3', 'c.example.net'], + ['ns1', 'z.example.org'], + ['techc', 'Max Muster'], + ['ns4', 'd.example.net'], + ['ns2', 'b.example.net'], + ), + ); + expect(result.nameServers).toEqual([ + 'z.example.org', + 'b.example.net', + 'c.example.net', + 'd.example.net', + ]); + expect(result.keys).toEqual(['ns3', 'ns1', 'techc', 'ns4', 'ns2']); + }); + + it('sortiert numerisch: ns10 nach ns9', () => { + const result = parseProfileNameServers( + profile(['ns10', 'j.example.net'], ['ns9', 'i.example.net'], ['ns1', 'a.example.net']), + ); + expect(result.nameServers).toEqual(['a.example.net', 'i.example.net', 'j.example.net']); + }); + + it('normalisiert Werte und verwirft Nicht-Rechnernamen', () => { + const result = parseProfileNameServers( + profile( + ['ns1', ' Z.Example.NET. '], + ['ns2', '192.0.2.1'], + ['ns3', ''], + ['ns4', 'kein rechner'], + ['ns5', 'a.example.org 192.0.2.1'], + ['ns6', 'b.example.org'], + ), + ); + expect(result.nameServers).toEqual(['z.example.net', 'b.example.org']); + }); + + it.each([ + ['nameserver1', 'nameserver2'], + ['NS_1', 'NS_2'], + ['default_ns1', 'default_ns2'], + ['nserver1', 'nserver2'], + ])('erkennt die Schluesselform %s', (k1, k2) => { + const result = parseProfileNameServers(profile([k2, 'b.example.net'], [k1, 'a.example.net'])); + expect(result.nameServers).toEqual(['a.example.net', 'b.example.net']); + }); + + it('akzeptiert flache Eintraege und ein einzelnes Objekt mit profiles', () => { + expect( + parseProfileNameServers([ + { key: 'ns2', value: 'b.example.net' }, + { key: 'ns1', value: 'a.example.net' }, + ]).nameServers, + ).toEqual(['a.example.net', 'b.example.net']); + expect( + parseProfileNameServers({ + profiles: [ + { key: 'ns1', value: 'a.example.net' }, + { key: 'ns2', value: 'b.example.net' }, + ], + }).nameServers, + ).toEqual(['a.example.net', 'b.example.net']); + }); + + it('gleiche Nummer mit zwei verschiedenen Rechnern: leer, nichts wird geraten', () => { + const result = parseProfileNameServers( + profile(['ns1', 'a.example.net'], ['default_ns1', 'x.example.net'], ['ns2', 'b.example.net']), + ); + expect(result.nameServers).toEqual([]); + }); + + it('gleiche Nummer mit gleichem Rechner zaehlt einmal, derselbe Rechner unter zwei Nummern bleibt einmal', () => { + expect( + parseProfileNameServers( + profile( + ['ns1', 'a.example.net'], + ['default_ns1', 'A.example.net'], + ['ns2', 'b.example.net'], + ), + ).nameServers, + ).toEqual(['a.example.net', 'b.example.net']); + expect( + parseProfileNameServers( + profile(['ns1', 'a.example.net'], ['ns2', 'a.example.net'], ['ns3', 'b.example.net']), + ).nameServers, + ).toEqual(['a.example.net', 'b.example.net']); + }); + + it('Listenschluessel nur als Ersatz, in der geschriebenen Reihenfolge', () => { + expect( + parseProfileNameServers(profile(['nameservers', 'b.example.org, a.example.org'])).nameServers, + ).toEqual(['b.example.org', 'a.example.org']); + // Nummerierte Schluessel haben Vorrang vor der Liste. + expect( + parseProfileNameServers( + profile(['nameservers', 'x.example.org, y.example.org'], ['ns1', 'a.example.net']), + ).nameServers, + ).toEqual(['a.example.net']); + }); + + it('zwei Listenschluessel mit verschiedenen Listen: leer', () => { + expect( + parseProfileNameServers( + profile( + ['nameservers', 'a.example.org, b.example.org'], + ['default_nameservers', 'c.example.org, d.example.org'], + ), + ).nameServers, + ).toEqual([]); + }); + + it.each([ + [null], + ['x'], + [{}], + [[1, 2]], + [undefined], + ])('Muell %j ergibt eine leere Liste', (input) => { + expect(parseProfileNameServers(input)).toEqual({ nameServers: [], keys: [] }); + }); +}); diff --git a/apps/api/src/domains/autodns-parse.ts b/apps/api/src/domains/autodns-parse.ts index 2f9a1b8..265db5e 100644 --- a/apps/api/src/domains/autodns-parse.ts +++ b/apps/api/src/domains/autodns-parse.ts @@ -1,4 +1,5 @@ import { domainToASCII } from 'node:url'; +import { HOSTNAME_PATTERN } from './domain-name'; /** * Reine Auswertung der AutoDNS-Antwortdaten (quick-261008-dts) — kein Nest, @@ -307,3 +308,96 @@ export function parseJobList(data: unknown): ParsedJobListEntry[] { } return result; } + +// --- Standard-Nameserver aus dem AutoDNS-Benutzerprofil (quick-261008-h3t) -------- + +export interface ParsedProfileNameServers { + /** Rechnernamen in der Reihenfolge der Positionsnummer im Profilschluessel. */ + nameServers: string[]; + /** Die gesehenen Profilschluessel (nur Namen, nie Werte) fuer die Protokollzeile. */ + keys: string[]; +} + +const MAX_PROFILE_KEYS = 50; +const MAX_PROFILE_KEY_LENGTH = 60; +const NUMBERED_KEY = /(?:nameserver|name[_-]server|nserver|ns)[_.-]?(\d{1,2})(?!\d)/; +const LIST_KEY = /^(?:default[_.-]?)?(?:nameservers?|name[_-]servers?|nservers?|ns)$/; + +/** + * Erkennt die Standard-Nameserver in der Antwort von + * `GET /user/{name}/{context}/profile` (UserProfileViews mit `profiles[]` aus + * `key`/`value`). + * + * [ASSUMED] Die Schluesselnamen der Standard-Nameserver im AutoDNS-Benutzerprofil + * sind nicht dokumentiert. Deshalb wird tolerant erkannt: ein Schluessel, der + * `ns`, `nserver` oder `nameserver` samt Nummer enthaelt (zum Beispiel + * `nameserver1`, `NS_2`, `default_ns3`); die Nummer bestimmt die Reihenfolge. + * Nur wenn so nichts erkannt wird, gilt ein Listenschluessel (`nameservers`) mit + * durch Komma/Leerzeichen getrennten Rechnernamen. Mehrdeutiges (dieselbe Nummer + * mit zwei verschiedenen Rechnern) ergibt eine leere Liste — es wird nie + * geraten. Pruefpunkt H-1 der Demo-Checkliste von quick-261008-h3t. + * Die Mindestanzahl prueft der Aufrufer. + */ +export function parseProfileNameServers(data: unknown): ParsedProfileNameServers { + const entries: Array<{ key: string; value: string }> = []; + const collect = (item: unknown): void => { + if (!isRecord(item)) return; + if (typeof item.key === 'string' && typeof item.value === 'string') { + entries.push({ key: item.key, value: item.value }); + } + if (Array.isArray(item.profiles)) { + for (const p of item.profiles) { + if (isRecord(p) && typeof p.key === 'string' && typeof p.value === 'string') { + entries.push({ key: p.key, value: p.value }); + } + } + } + }; + if (Array.isArray(data)) for (const item of data) collect(item); + else collect(data); + + const keys: string[] = []; + for (const { key } of entries) { + const shown = key.trim().slice(0, MAX_PROFILE_KEY_LENGTH); + if (shown !== '' && !keys.includes(shown) && keys.length < MAX_PROFILE_KEYS) keys.push(shown); + } + + const hostOf = (value: string): string | null => { + const host = value.trim().toLowerCase().replace(/\.$/, ''); + return HOSTNAME_PATTERN.test(host) ? host : null; + }; + + // Nummerierte Schluessel. + const byPosition = new Map(); + let ambiguous = false; + for (const { key, value } of entries) { + const match = NUMBERED_KEY.exec(key.trim().toLowerCase()); + if (!match) continue; + const host = hostOf(value); + if (host === null) continue; + const position = Number.parseInt(match[1] as string, 10); + const known = byPosition.get(position); + if (known !== undefined && known !== host) ambiguous = true; + else byPosition.set(position, host); + } + if (ambiguous) return { nameServers: [], keys }; + if (byPosition.size > 0) { + const ordered = [...byPosition.entries()].sort((a, b) => a[0] - b[0]).map(([, host]) => host); + return { nameServers: [...new Set(ordered)], keys }; + } + + // Ersatz: ein Listenschluessel. + const lists: string[][] = []; + for (const { key, value } of entries) { + if (!LIST_KEY.test(key.trim().toLowerCase())) continue; + const tokens = value.split(/[\s,;]+/).filter((t) => t !== ''); + if (tokens.length === 0) continue; + const hosts = tokens.map(hostOf); + if (hosts.some((h) => h === null)) continue; + lists.push([...new Set(hosts as string[])]); + } + const first = lists[0]; + if (first === undefined) return { nameServers: [], keys }; + if (lists.some((l) => l.join(',') !== first.join(','))) return { nameServers: [], keys }; + return { nameServers: first, keys }; +} diff --git a/apps/api/src/domains/domain-name.ts b/apps/api/src/domains/domain-name.ts index 9eb0183..bc77459 100644 --- a/apps/api/src/domains/domain-name.ts +++ b/apps/api/src/domains/domain-name.ts @@ -13,6 +13,10 @@ const LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?'; const DOMAIN_PATTERN = new RegExp(`^(?:${LABEL}\\.)+${LABEL}$`); const MAX_TOTAL_LENGTH = 253; +/** Rechnername: Labels aus Buchstaben, Ziffern, Bindestrich; mindestens zwei Labels. */ +export const HOSTNAME_PATTERN = + /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z](?:[a-z0-9-]{0,61}[a-z0-9])$/; + export interface NormalizedDomain { /** ASCII-/Punycode-Form — das geht an AutoDNS. */ ascii: string; diff --git a/apps/api/src/domains/domains-orders.service.spec.ts b/apps/api/src/domains/domains-orders.service.spec.ts index cb0e17f..dcdcb6b 100644 --- a/apps/api/src/domains/domains-orders.service.spec.ts +++ b/apps/api/src/domains/domains-orders.service.spec.ts @@ -89,6 +89,31 @@ const PAYLOAD = { availabilityCheckedAt: '2026-10-08T10:00:00.000Z', }; +/** Standard-Nameserver im Profil, bewusst nicht alphabetisch und nicht in Schluesselreihenfolge geliefert. */ +const PROFILE_NAMESERVERS = [ + 'zeta.example.net', + 'alpha.example.org', + 'mid.example.com', + 'beta.example.net', +]; + +function profileReply() { + return reply( + 200, + envelope([ + { + profiles: [ + { key: 'ns3', value: 'mid.example.com' }, + { key: 'ns1', value: 'Zeta.example.net.' }, + { key: 'techc', value: 'Max Muster' }, + { key: 'ns4', value: 'beta.example.net' }, + { key: 'ns2', value: 'alpha.example.org' }, + ], + }, + ]), + ); +} + function makeHarness(opts: { environment?: 'DEMO' | 'LIVE'; timeoutMs?: number } = {}) { const state = { orders: [] as OrderRow[], @@ -102,9 +127,15 @@ function makeHarness(opts: { environment?: 'DEMO' | 'LIVE'; timeoutMs?: number } throw new Error('kein Netz erwartet'); }; + // Das Benutzerprofil (Standard-Nameserver) hat einen eigenen, ueberschreibbaren Antwortgeber. + let profileResponder: Responder = () => profileReply(); + const fetchImpl = vi.fn(async (url: string, init: any) => { const call: Call = { url, init }; calls.push(call); + if (init.method === 'GET' && url.endsWith('/profile')) { + return (await profileResponder(call)) as never; + } return (await responder(call)) as never; }); @@ -223,6 +254,9 @@ function makeHarness(opts: { environment?: 'DEMO' | 'LIVE'; timeoutMs?: number } respond: (r: Responder) => { responder = r; }, + respondProfile: (r: Responder) => { + profileResponder = r; + }, }; } @@ -337,7 +371,6 @@ describe('createOrder', () => { adminContactId: 11, techContactId: 22, zoneContactId: 22, - nameServers: ['NS1.example.com ', 'ns2.example.com'], }; function free() { @@ -364,14 +397,14 @@ describe('createOrder', () => { adminContactId: 11, techContactId: 22, zoneContactId: 22, - nameServers: ['ns1.example.com', 'ns2.example.com'], + nameServers: PROFILE_NAMESERVERS, periodYears: 1, price: { amount: 4.9, currency: 'EUR' }, }); expect(typeof h.state.orders[0].payload.availabilityCheckedAt).toBe('string'); expect(result.contacts.owner).toEqual({ id: 11, displayName: 'Muster GmbH' }); expect(result.contacts.zone).toEqual({ id: 22, displayName: 'Erika Muster' }); - expect(result.nameServers).toEqual(['ns1.example.com', 'ns2.example.com']); + expect(result.nameServers).toEqual(PROFILE_NAMESERVERS); expect(result.order).toMatchObject({ domainName: 'beispiel.de', status: 'DRAFT' }); expect(result.version).toBe(h.state.orders[0].updatedAt.toISOString()); // Der Entwurf selbst bestellt nichts. @@ -388,28 +421,60 @@ describe('createOrder', () => { expect(h.state.orders).toHaveLength(0); }); - it.each([ - [['ns1.example.com'], 'tooFewNameServers'], - [ - [ - 'a.example.com', - 'b.example.com', - 'c.example.com', - 'd.example.com', - 'e.example.com', - 'f.example.com', - 'g.example.com', - ], - 'tooManyNameServers', - ], - [['ns1.example.com', 'kein rechner'], 'invalidNameServer'], - ])('Nameserver %j -> BadRequest %s', async (nameServers, code) => { + it('ein vom Browser gesendeter Nameserver-Satz wird ignoriert (h3t, D-03)', async () => { const h = makeHarness(); h.respond(free); - await expect( - h.service.createOrder('t1', 'u1', { ...dto, nameServers } as any), - ).rejects.toMatchObject({ response: { code } }); + const result = await h.service.createOrder('t1', 'u1', { + ...dto, + nameServers: ['evil.example.com', 'x.example.com'], + } as any); + expect(result.nameServers).toEqual(PROFILE_NAMESERVERS); + expect(h.state.orders[0].payload.nameServers).toEqual(PROFILE_NAMESERVERS); + }); + + it('Profil ohne Nameserver -> 409 noDefaultNameServers, keine Zeile, keine Verfuegbarkeitsabfrage', async () => { + const h = makeHarness(); + h.respond(free); + h.respondProfile(() => + reply(200, envelope([{ profiles: [{ key: 'techc', value: 'Max Muster' }] }])), + ); + await expect(h.service.createOrder('t1', 'u1', dto as any)).rejects.toMatchObject({ + status: 409, + response: { code: 'noDefaultNameServers' }, + }); expect(h.state.orders).toHaveLength(0); + expect(h.calls.some((c) => c.url.endsWith('/domainstudio'))).toBe(false); + }); + + it('Profil mit nur einem Nameserver -> 409 noDefaultNameServers', async () => { + const h = makeHarness(); + h.respond(free); + h.respondProfile(() => + reply(200, envelope([{ profiles: [{ key: 'ns1', value: 'a.example.net' }] }])), + ); + await expect(h.service.createOrder('t1', 'u1', dto as any)).rejects.toMatchObject({ + response: { code: 'noDefaultNameServers' }, + }); + expect(h.state.orders).toHaveLength(0); + }); + + it('Profilabfrage mit HTTP 500 -> abgelehnt, keine Zeile', async () => { + const h = makeHarness(); + h.respond(free); + h.respondProfile(() => reply(500, 'kaputt')); + await expect(h.service.createOrder('t1', 'u1', dto as any)).rejects.toBeDefined(); + expect(h.state.orders).toHaveLength(0); + }); + + it('ein vorhandener Entwurf liest das Profil neu und bekommt einen neuen Stand (WR-02)', async () => { + const h = makeHarness(); + const existing = h.addOrder(); + const before = existing.updatedAt.toISOString(); + h.respond(free); + const result = await h.service.createOrder('t1', 'u1', dto as any); + expect(h.calls.filter((c) => c.url.endsWith('/profile'))).toHaveLength(1); + expect(result.nameServers).toEqual(PROFILE_NAMESERVERS); + expect(result.version).not.toBe(before); }); it('fehlender Kontakt -> BadRequest', async () => { @@ -1348,3 +1413,107 @@ describe('listOrders', () => { }); }); }); + +describe('getProfileNameServers (h3t)', () => { + it('liest das Profil mit genau einem GET und liefert die Nameserver in Schluesselreihenfolge', async () => { + const h = makeHarness(); + const result = await h.service.getProfileNameServers('t1'); + expect(result).toEqual({ environment: 'DEMO', nameServers: PROFILE_NAMESERVERS }); + expect(h.calls).toHaveLength(1); + expect(h.calls[0].url).toBe('https://api.demo.autodns.com/v1/user/api-user/4/profile'); + expect(h.calls[0].init.method).toBe('GET'); + }); + + it('kodiert einen Benutzernamen mit Leerzeichen im Pfad', async () => { + const h = makeHarness(); + h.settings.getActiveCredentials.mockResolvedValueOnce({ + environment: 'DEMO', + credentials: { ...CREDENTIALS_DEMO, user: 'api user' }, + configVersion: 1, + }); + await h.service.getProfileNameServers('t1'); + expect(h.calls[0].url).toBe('https://api.demo.autodns.com/v1/user/api%20user/4/profile'); + }); + + it('Profil ohne Nameserver-Schluessel -> 409 noDefaultNameServers', async () => { + const h = makeHarness(); + h.respondProfile(() => + reply(200, envelope([{ profiles: [{ key: 'techc', value: 'Max Muster' }] }])), + ); + await expect(h.service.getProfileNameServers('t1')).rejects.toMatchObject({ + status: 409, + response: { code: 'noDefaultNameServers' }, + }); + }); + + it('AutoDNS 401 -> 502 autodnsAuth', async () => { + const h = makeHarness(); + h.respondProfile(() => reply(401, '')); + await expect(h.service.getProfileNameServers('t1')).rejects.toMatchObject({ + status: 502, + response: { code: 'autodnsAuth' }, + }); + }); + + it('Zeitablauf -> 504', async () => { + const h = makeHarness({ timeoutMs: 20 }); + h.respondProfile(() => new Promise(() => {})); + await expect(h.service.getProfileNameServers('t1')).rejects.toMatchObject({ status: 504 }); + }); +}); + +describe('Nameserver aus AutoDNS (h3t)', () => { + const dto = { + domain: 'beispiel.de', + ownerContactId: 11, + adminContactId: 11, + techContactId: 22, + zoneContactId: 22, + }; + + it('Kette: Entwurf -> Bestaetigung -> genau ein POST /domain mit den Profil-Nameservern in Profil-Reihenfolge', async () => { + const h = makeHarness(); + h.respond((call) => { + if (call.url.endsWith('/domainstudio')) { + return studioReply('beispiel.de', 'FREE', [ + { amount: 4.9, currency: 'EUR', period: { unit: 'YEAR', period: 1 } }, + ]); + } + return reply(200, envelope([{ id: 77, status: 'RUNNING' }])); + }); + const summary = await h.service.createOrder('t1', 'u1', dto as any); + expect(summary.nameServers).toEqual(PROFILE_NAMESERVERS); + await h.service.submitOrder( + 't1', + { id: 'u1', username: 'admin' }, + summary.order.id, + summary.version, + ); + const posts = h.calls.filter((c) => c.init.method === 'POST' && c.url.endsWith('/domain')); + expect(posts).toHaveLength(1); + expect(JSON.parse(posts[0].init.body as string).nameServers).toEqual([ + { name: 'zeta.example.net' }, + { name: 'alpha.example.org' }, + { name: 'mid.example.com' }, + { name: 'beta.example.net' }, + ]); + }); + + it.each([ + [[]], + [['ns1.example.com']], + ])('Entwurf mit Nameservern %j -> 400 orderInvalid vor dem Anspruch, ohne Netz', async (nameServers) => { + const h = makeHarness(); + const row = h.addOrder({ payload: { ...PAYLOAD, nameServers } }); + await expect( + h.service.submitOrder( + 't1', + { id: 'u1', username: 'admin' }, + row.id, + row.updatedAt.toISOString(), + ), + ).rejects.toMatchObject({ status: 400, response: { code: 'orderInvalid' } }); + expect(h.orderApi.updateMany).not.toHaveBeenCalled(); + expect(h.fetchImpl).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/domains/domains-orders.service.ts b/apps/api/src/domains/domains-orders.service.ts index 8e833fc..5e3c863 100644 --- a/apps/api/src/domains/domains-orders.service.ts +++ b/apps/api/src/domains/domains-orders.service.ts @@ -1,4 +1,5 @@ import { + BadGatewayException, BadRequestException, ConflictException, Injectable, @@ -20,11 +21,12 @@ import { parseDomainStudio, parseJob, parseJobList, + parseProfileNameServers, } from './autodns-parse'; import { normalizeDomainName, splitDomain } from './domain-name'; import { autodnsFailureToHttp, type DomainsEnvironment } from './domains.types'; import { DomainsDirectoryService } from './domains-directory.service'; -import { DomainsSettingsService, HOSTNAME_PATTERN } from './domains-settings.service'; +import { DomainsSettingsService } from './domains-settings.service'; import type { CreateDomainsOrderDto } from './dto/domains-order.dto'; export type DomainOrderStatus = @@ -45,8 +47,8 @@ const JOB_SEARCH_TOLERANCE_MS = 5 * 60_000; const LIST_MAX_ORDERS = 200; /** Trefferzahl der Auftragssuche beim Abgleich; eine volle Liste kann abgeschnitten sein. */ const JOB_SEARCH_LIMIT = 10; +/** .de verlangt mindestens zwei Nameserver; weniger gilt als "nicht hinterlegt". */ const MIN_NAMESERVERS = 2; -const MAX_NAMESERVERS = 6; type ReconcileOutcome = 'found' | 'not-found' | 'inconclusive'; @@ -165,6 +167,11 @@ const ERR = { code: 'notCancelable', message: 'Dieser Auftrag kann nicht mehr verworfen werden.', }, + noDefaultNameServers: { + code: 'noDefaultNameServers', + message: + 'In AutoDNS sind keine Standard-Nameserver hinterlegt. Bitte hinterlegen Sie mindestens zwei Nameserver als Standard in AutoDNS; bis dahin ist keine Registrierung möglich.', + }, orderNotFound: { code: 'orderNotFound', message: 'Der Auftrag wurde nicht gefunden.' }, invalidDomain: { code: 'invalidDomain', @@ -323,31 +330,42 @@ export class DomainsOrdersService { // --- Entwurf ----------------------------------------------------------------- - private normalizeNameServers(raw: string[]): string[] { - const list = [...new Set((raw ?? []).map((n) => String(n).trim().toLowerCase()))].filter( - (n) => n.length > 0, - ); - if (list.length < MIN_NAMESERVERS) { - throw new BadRequestException({ - code: 'tooFewNameServers', - message: 'Bitte geben Sie mindestens zwei Nameserver an.', + /** + * Liest die Standard-Nameserver aus dem AutoDNS-Benutzerprofil des + * eingetragenen Benutzers (genau EIN GET, keine Wiederholung). Die Liste ist + * die einzige Quelle der Nameserver einer Registrierung: nichts wird geraten, + * ergaenzt oder gekuerzt. Weniger als zwei erkannte Eintraege gelten als + * "nicht hinterlegt" (409), ein Lesefehler wird als AutoDNS-Fehler gemeldet. + */ + private async readProfileNameServers(credentials: AutodnsCredentials): Promise { + const path = `/user/${encodeURIComponent(credentials.user)}/${credentials.context}/profile`; + let result: AutodnsResult; + try { + result = await this.callRaw(credentials, 'GET', path); + } catch { + throw new BadGatewayException({ + code: 'autodnsError', + message: 'Tessera konnte die Standard-Nameserver nicht aus AutoDNS lesen.', }); } - if (list.length > MAX_NAMESERVERS) { - throw new BadRequestException({ - code: 'tooManyNameServers', - message: 'Höchstens sechs Nameserver sind möglich.', - }); + if (!result.ok) throw autodnsFailureToHttp(result); + const parsed = parseProfileNameServers(result.data); + if (parsed.nameServers.length < MIN_NAMESERVERS) { + // Nur Schluesselnamen, nie Werte oder Zugangsdaten. + this.logger.warn( + `Keine Standard-Nameserver im AutoDNS-Profil erkannt (${credentials.environment}); Schluessel im Profil: ${parsed.keys.join(', ') || '(keine)'}`, + ); + throw new ConflictException(ERR.noDefaultNameServers); } - for (const name of list) { - if (!HOSTNAME_PATTERN.test(name)) { - throw new BadRequestException({ - code: 'invalidNameServer', - message: `Der Nameserver „${name}“ ist kein gültiger Rechnername.`, - }); - } - } - return list; + return parsed.nameServers; + } + + /** Standard-Nameserver des aktiven Systems zur Anzeige in "Registrieren". */ + async getProfileNameServers( + tenantId: string, + ): Promise<{ environment: DomainsEnvironment; nameServers: string[] }> { + const { environment, credentials } = await this.settings.getActiveCredentials(tenantId); + return { environment, nameServers: await this.readProfileNameServers(credentials) }; } async createOrder( @@ -357,7 +375,6 @@ export class DomainsOrdersService { ): Promise { const normalized = normalizeDomainName(dto.domain); if (!normalized) throw new BadRequestException(ERR.invalidDomain); - const nameServers = this.normalizeNameServers(dto.nameServers); const contactIds = [ dto.ownerContactId, dto.adminContactId, @@ -381,6 +398,10 @@ export class DomainsOrdersService { })) as OrderRow | null; if (existing && existing.status !== 'DRAFT') throw new ConflictException(ERR.orderOpen); + // Die Nameserver liest der Server selbst aus dem AutoDNS-Profil; was der + // Browser anzeigt, ist nur zur Kontrolle (D-03). Fehlen sie, entsteht kein Entwurf. + const nameServers = await this.readProfileNameServers(credentials); + // Die Verfuegbarkeit wird serverseitig neu geprueft — dem Browser traut hier niemand. const availability = await this.availabilityFor(credentials, domain, normalized.unicode); if (!availability.available) throw new ConflictException(ERR.notAvailable); @@ -503,7 +524,7 @@ export class DomainsOrdersService { if (before.status !== 'DRAFT') throw new ConflictException(ERR.alreadySubmitted); if (!this.sameVersion(before.updatedAt, version)) throw new ConflictException(ERR.orderChanged); const payload = readPayload(before.payload); - if (!payload) { + if (!payload || payload.nameServers.length < MIN_NAMESERVERS) { throw new BadRequestException({ code: 'orderInvalid', message: 'Der Auftrag ist unvollständig. Bitte legen Sie ihn neu an.', diff --git a/apps/api/src/domains/domains-settings.service.ts b/apps/api/src/domains/domains-settings.service.ts index 6e15b8d..382ab26 100644 --- a/apps/api/src/domains/domains-settings.service.ts +++ b/apps/api/src/domains/domains-settings.service.ts @@ -15,6 +15,7 @@ import { type AutodnsRateLimiter, autodnsRequest, } from './autodns-client'; +import { HOSTNAME_PATTERN } from './domain-name'; import { AUTODNS_AUTH_MESSAGE, type DomainsConnectionTestResult, @@ -40,10 +41,6 @@ const DECRYPT_FAILED = { const MIN_NAMESERVERS = 2; const MAX_NAMESERVERS = 6; -/** Rechnername: Labels aus Buchstaben, Ziffern, Bindestrich; mindestens zwei Labels. */ -export const HOSTNAME_PATTERN = - /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z](?:[a-z0-9-]{0,61}[a-z0-9])$/; - interface ConfigRow { environment: DomainsEnvironment; demoUser: string | null; diff --git a/apps/api/src/domains/domains.controller.spec.ts b/apps/api/src/domains/domains.controller.spec.ts index 5aae9fd..8e19247 100644 --- a/apps/api/src/domains/domains.controller.spec.ts +++ b/apps/api/src/domains/domains.controller.spec.ts @@ -21,6 +21,7 @@ const MANAGE_HANDLERS = [ 'createContact', 'assignContacts', 'checkAvailability', + 'getNameServers', 'createOrder', 'submitOrder', 'cancelOrder', @@ -50,6 +51,7 @@ function makeDirectory() { function makeOrders() { return { checkAvailability: vi.fn(async (..._a: unknown[]) => ({})), + getProfileNameServers: vi.fn(async (..._a: unknown[]) => ({})), listOrders: vi.fn(async (..._a: unknown[]) => []), createOrder: vi.fn(async (..._a: unknown[]) => ({})), refreshOpenOrders: vi.fn(async (..._a: unknown[]) => []), @@ -106,6 +108,7 @@ describe('DomainsController — Metadaten', () => { expect(route('updateCustomer')).toEqual([2, 'customers/:id']); expect(route('deleteCustomer')).toEqual([3, 'customers/:id']); expect(route('checkAvailability')).toEqual([1, 'availability']); + expect(route('getNameServers')).toEqual([0, 'name-servers']); expect(route('listOrders')).toEqual([0, 'orders']); expect(route('createOrder')).toEqual([1, 'orders']); expect(route('refreshOrders')).toEqual([1, 'orders/refresh']); @@ -223,12 +226,14 @@ describe('DomainsController — Auftraege: Verhalten', () => { const orders = makeOrders(); const c = new DomainsController(makeSettings() as any, makeDirectory() as any, orders as any); await c.checkAvailability(req('t1'), { domain: 'beispiel.de' }); + await c.getNameServers(req('t1')); await c.listOrders(req('t1')); await c.createOrder(req('t1'), user, { domain: 'beispiel.de' } as any); await c.refreshOrders(req('t1')); await c.submitOrder(req('t1'), user, id, { version: '2026-10-08T10:00:00.000Z' }); await c.cancelOrder(req('t1'), id); expect(orders.checkAvailability).toHaveBeenCalledWith('t1', 'beispiel.de'); + expect(orders.getProfileNameServers).toHaveBeenCalledWith('t1'); expect(orders.listOrders).toHaveBeenCalledWith('t1'); expect(orders.createOrder).toHaveBeenCalledWith('t1', 'u1', { domain: 'beispiel.de' }); expect(orders.refreshOpenOrders).toHaveBeenCalledWith('t1'); diff --git a/apps/api/src/domains/domains.controller.ts b/apps/api/src/domains/domains.controller.ts index 3acfa3c..c3feeb2 100644 --- a/apps/api/src/domains/domains.controller.ts +++ b/apps/api/src/domains/domains.controller.ts @@ -55,7 +55,7 @@ function mayRefresh(req: AuthenticatedRequest, value: string | undefined): boole * Verwalten (`@ModuleManage('domains')`, Administratoren und Benutzer mit * der Freigabestufe Verwalten): GET/PUT settings, POST * connection-test, Kunden und Kontakte anlegen/aendern/zuordnen, - * Verfuegbarkeit pruefen, Bestellungen anlegen, abschicken, + * Verfuegbarkeit pruefen, Standard-Nameserver aus AutoDNS lesen, Bestellungen anlegen, abschicken, * abbrechen. * Auf Verwalten-Handlern steht NIE ein Rollen-Decorator — der globale * RolesGuard wuerde Verwalter sonst aussperren. @@ -154,6 +154,12 @@ export class DomainsController { return this.orders.checkAvailability(this.requireTenantId(req), dto.domain); } + @Get('name-servers') + @ModuleManage('domains') + async getNameServers(@Req() req: AuthenticatedRequest) { + return this.orders.getProfileNameServers(this.requireTenantId(req)); + } + @Get('orders') async listOrders(@Req() req: AuthenticatedRequest) { return this.orders.listOrders(this.requireTenantId(req)); diff --git a/apps/api/src/domains/dto/domains-order.dto.ts b/apps/api/src/domains/dto/domains-order.dto.ts index 58af03d..3384cf7 100644 --- a/apps/api/src/domains/dto/domains-order.dto.ts +++ b/apps/api/src/domains/dto/domains-order.dto.ts @@ -1,14 +1,4 @@ -import { - ArrayMaxSize, - ArrayMinSize, - IsArray, - IsInt, - IsISO8601, - IsNotEmpty, - IsString, - MaxLength, - Min, -} from 'class-validator'; +import { IsInt, IsISO8601, IsNotEmpty, IsString, MaxLength, Min } from 'class-validator'; /** Verfuegbarkeitspruefung einer Domain (quick-261008-dts, D-K). */ export class CheckAvailabilityDto { @@ -20,7 +10,9 @@ export class CheckAvailabilityDto { /** * Entwurf einer Registrierung (D-O): alle vier Kontakte sind Pflicht, die - * Laufzeit steht fest bei einem Jahr und ist deshalb kein Feld. + * Laufzeit steht fest bei einem Jahr und ist deshalb kein Feld. Die Nameserver + * kommen aus AutoDNS (Benutzerprofil), nie vom Browser; ein mitgeschicktes Feld + * `nameServers` entfernt die Whitelist der ValidationPipe. */ export class CreateDomainsOrderDto { @IsString() @@ -43,13 +35,6 @@ export class CreateDomainsOrderDto { @IsInt() @Min(1) zoneContactId!: number; - - @IsArray() - @ArrayMinSize(2) - @ArrayMaxSize(6) - @IsString({ each: true }) - @MaxLength(253, { each: true }) - nameServers!: string[]; } /** diff --git a/apps/api/src/module-registry/module-manage-handlers.spec.ts b/apps/api/src/module-registry/module-manage-handlers.spec.ts index 245564c..77ef5dd 100644 --- a/apps/api/src/module-registry/module-manage-handlers.spec.ts +++ b/apps/api/src/module-registry/module-manage-handlers.spec.ts @@ -95,6 +95,7 @@ describe('Umgestellte Handler (Verwalten)', () => { 'createContact', 'assignContacts', 'checkAvailability', + 'getNameServers', 'createOrder', 'submitOrder', 'cancelOrder',