From f5434b3b31377cc01b7d4416180e1b8e0b175afc Mon Sep 17 00:00:00 2001 From: Nils Wisiol Date: Sat, 10 Jan 2026 12:00:28 +0100 Subject: [PATCH 1/7] chore(webapp): migration to vuetify 3 AI-assisted changes. I played with everything I could think of on the website: direct debit donation account creation, login, 2fa enabled, change email, delete account domain list, filtering rrset creation and deletion scrolled through all pages of the web site --- www/webapp/.gitignore | 1 + www/webapp/package.json | 23 +- www/webapp/src/App.vue | 224 ++++++++++-------- .../ActivateAccountActionHandler.vue | 11 +- ...eAccountWithOverrideTokenActionHandler.vue | 20 +- .../components/CreateTOTPActionHandler.vue | 7 +- .../src/components/DonateDirectDebitForm.vue | 27 ++- www/webapp/src/components/ErrorAlert.vue | 2 +- .../src/components/Field/GenericCaptcha.vue | 20 +- .../src/components/Field/GenericCheckbox.vue | 20 +- .../src/components/Field/GenericSwitchbox.vue | 20 +- .../src/components/Field/GenericText.vue | 39 ++- .../src/components/Field/GenericTextarea.vue | 27 ++- www/webapp/src/components/Field/RRSetType.vue | 16 +- www/webapp/src/components/Field/RecordA.vue | 3 +- .../src/components/Field/RecordAAAA.vue | 3 +- www/webapp/src/components/Field/RecordCAA.vue | 3 +- .../src/components/Field/RecordCNAME.vue | 15 +- .../src/components/Field/RecordDNSKEY.vue | 3 +- www/webapp/src/components/Field/RecordDS.vue | 3 +- .../src/components/Field/RecordItem.vue | 124 +++++++--- .../src/components/Field/RecordList.vue | 40 +++- www/webapp/src/components/Field/RecordMX.vue | 15 +- www/webapp/src/components/Field/RecordNS.vue | 15 +- .../src/components/Field/RecordOPENPGPKEY.vue | 3 +- www/webapp/src/components/Field/RecordSRV.vue | 15 +- .../src/components/Field/RecordSVCB.vue | 15 +- .../src/components/Field/RecordSubnet.vue | 3 +- .../src/components/Field/RecordTLSA.vue | 3 +- www/webapp/src/components/Field/RecordTXT.vue | 10 +- www/webapp/src/components/Field/TTL.vue | 27 ++- www/webapp/src/components/Field/TimeAgo.vue | 11 +- www/webapp/src/components/QrcodeVue.vue | 61 +++++ .../components/ResetPasswordActionHandler.vue | 6 +- www/webapp/src/main.js | 25 +- www/webapp/src/plugins/vuetify.js | 33 ++- www/webapp/src/router/index.js | 74 +++--- www/webapp/src/views/AboutPage.vue | 2 +- www/webapp/src/views/ChangeEmail.vue | 20 +- www/webapp/src/views/ConfirmationPage.vue | 8 +- .../src/views/Console/DomainSetupDialog.vue | 17 +- .../src/views/Console/TOTPVerifyDialog.vue | 71 ++++-- www/webapp/src/views/CrudList.vue | 188 ++++++++++----- www/webapp/src/views/CrudListToken.vue | 2 +- www/webapp/src/views/DeleteAccount.vue | 16 +- www/webapp/src/views/DomainSetup.vue | 48 ++-- www/webapp/src/views/DomainSetupPage.vue | 5 +- www/webapp/src/views/DonatePage.vue | 110 +++++---- www/webapp/src/views/DynSetup.vue | 31 ++- www/webapp/src/views/HomePage.vue | 74 +++--- www/webapp/src/views/LoginPage.vue | 17 +- www/webapp/src/views/MFA.vue | 11 +- www/webapp/src/views/PrivacyPolicy.vue | 2 +- www/webapp/src/views/ResetPassword.vue | 10 +- www/webapp/src/views/SignUp.vue | 34 ++- www/webapp/src/views/TermsPage.vue | 2 +- www/webapp/src/views/WelcomePage.vue | 5 +- www/webapp/vite.config.js | 20 +- 58 files changed, 1024 insertions(+), 636 deletions(-) create mode 100644 www/webapp/src/components/QrcodeVue.vue diff --git a/www/webapp/.gitignore b/www/webapp/.gitignore index 46955aa0f..7e07b0286 100644 --- a/www/webapp/.gitignore +++ b/www/webapp/.gitignore @@ -1,5 +1,6 @@ .DS_Store node_modules +.vite /tests/e2e/videos/ /tests/e2e/screenshots/ diff --git a/www/webapp/package.json b/www/webapp/package.json index 11aa2b0d1..5585bc742 100644 --- a/www/webapp/package.json +++ b/www/webapp/package.json @@ -7,7 +7,8 @@ "serve": "vite preview", "build": "vite build", "lint": "eslint --ignore-path .gitignore --no-fix src/**/*.{vue,js,json}", - "lint:fix": "eslint --ignore-path .gitignore --fix src/**/*.{vue,js,json}" + "lint:fix": "eslint --ignore-path .gitignore --fix src/**/*.{vue,js,json}", + "postinstall": "vue-demi-switch 3" }, "type": "module", "engines": { @@ -16,26 +17,30 @@ "dependencies": { "@fontsource/roboto": "^5.0.3", "@mdi/js": "~7.4.47", + "@vuelidate/core": "^2.0.3", + "@vuelidate/validators": "^2.0.4", "axios": "^1.4.0", "date-fns": "^4.1.0", "pinia": "^2.0.30", - "vue": "~2.7.14", - "vue-router": "~3.6.5", - "vuelidate": "^0.7.7", - "vuetify": "^2.7.0" + "vue": "^3.4.19", + "vue-router": "^4.3.0", + "vuetify": "^3.7.5" }, "devDependencies": { "@vitejs/plugin-legacy": "^6.0.0", - "@vitejs/plugin-vue2": "^2.3.3", + "@vitejs/plugin-vue": "^5.2.0", "eslint": "^8.45.0", "eslint-import-resolver-alias": "^1.1.2", "eslint-plugin-import": "^2.27.5", "eslint-plugin-vue": "^9.15.1", "eslint-plugin-vue-scoped-css": "^2.6.1", - "eslint-plugin-vuetify": "^1.1.0", + "eslint-plugin-vuetify": "^2.1.1", "sass": "~1.83.4", - "unplugin-vue-components": "^28.0.0", "vite": "^6.0.11", - "vuetify-loader": "~1.9.1" + "vite-plugin-vuetify": "^2.0.4", + "vue-demi": "^0.14.10" + }, + "overrides": { + "vue-demi": "^0.14.10" } } diff --git a/www/webapp/src/App.vue b/www/webapp/src/App.vue index 4499155f6..e04680950 100644 --- a/www/webapp/src/App.vue +++ b/www/webapp/src/App.vue @@ -1,90 +1,91 @@ @@ -50,6 +49,10 @@ export default { type: Array, default: () => [], }, + modelValue: { + type: [String, Number], + required: false, + }, value: { type: [String, Number], required: false, @@ -59,9 +62,19 @@ export default { required: false, }, }, + computed: { + inputValue() { + return this.modelValue ?? this.value; + }, + }, methods: { - changed(event, e) { - this.$emit(event, e); + updateValue(value) { + this.$emit('update:modelValue', value); + this.$emit('input', value); + this.$emit('dirty'); + }, + handleKeyup(event) { + this.$emit('keyup', event); this.$emit('dirty'); }, }, diff --git a/www/webapp/src/components/Field/RRSetType.vue b/www/webapp/src/components/Field/RRSetType.vue index ed63b1a35..d6f172857 100644 --- a/www/webapp/src/components/Field/RRSetType.vue +++ b/www/webapp/src/components/Field/RRSetType.vue @@ -5,11 +5,11 @@ :error-messages="errorMessages" hint="You can also enter other types. For a full list, check the documentation." :persistent-hint="!readonly" - :value="value" + :model-value="inputValue" :items="types" :required="required" :rules="[v => !required || !!v || 'Required.']" - @input="input($event)" + @update:modelValue="input" /> @@ -37,9 +37,13 @@ export default { type: Boolean, default: false, }, + modelValue: { + type: String, + required: false, + }, value: { type: String, - required: true, + required: false, }, }, data: () => ({ @@ -60,8 +64,14 @@ export default { 'DS', ], }), + computed: { + inputValue() { + return this.modelValue ?? this.value; + }, + }, methods: { input(event) { + this.$emit('update:modelValue', event); this.$emit('input', event); }, }, diff --git a/www/webapp/src/components/Field/RecordA.vue b/www/webapp/src/components/Field/RecordA.vue index 868ccc184..20e8ce2be 100644 --- a/www/webapp/src/components/Field/RecordA.vue +++ b/www/webapp/src/components/Field/RecordA.vue @@ -1,10 +1,11 @@ diff --git a/www/webapp/src/components/ResetPasswordActionHandler.vue b/www/webapp/src/components/ResetPasswordActionHandler.vue index f5b29363a..577b27b4e 100644 --- a/www/webapp/src/components/ResetPasswordActionHandler.vue +++ b/www/webapp/src/components/ResetPasswordActionHandler.vue @@ -3,7 +3,7 @@
h(App) -}).$mount('#app') +app.use(pinia) +app.use(router) +app.use(vuetify) + +app.mount('#app') diff --git a/www/webapp/src/plugins/vuetify.js b/www/webapp/src/plugins/vuetify.js index b616aac30..7f38e5e85 100644 --- a/www/webapp/src/plugins/vuetify.js +++ b/www/webapp/src/plugins/vuetify.js @@ -1,21 +1,30 @@ -import Vue from 'vue'; -import Vuetify from 'vuetify/lib'; -import colors from 'vuetify/lib/util/colors' +import 'vuetify/styles' +import { createVuetify } from 'vuetify' +import { aliases, mdi } from 'vuetify/iconsets/mdi-svg' +import { VDataTable, VOtpInput } from 'vuetify/components' +import colors from 'vuetify/util/colors' - -Vue.use(Vuetify); - - -export default new Vuetify({ +export default createVuetify({ + components: { + VDataTable, + VOtpInput, + }, icons: { - iconfont: 'mdiSvg', // 'mdi' || 'mdiSvg' || 'md' || 'fa' || 'fa4' || 'faSvg' + defaultSet: 'mdi', + aliases, + sets: { + mdi, + }, }, theme: { + defaultTheme: 'light', themes: { light: { - primary: colors.amber, - secondary: colors.lightBlue.darken1, - accent: colors.amber.accent4, + colors: { + primary: colors.amber.base, + secondary: colors.lightBlue.darken1, + accent: colors.amber.accent4, + }, }, }, }, diff --git a/www/webapp/src/router/index.js b/www/webapp/src/router/index.js index 123fb6429..681a3d846 100644 --- a/www/webapp/src/router/index.js +++ b/www/webapp/src/router/index.js @@ -1,8 +1,20 @@ -import VueRouter from 'vue-router' +import { createRouter, createWebHistory } from 'vue-router' import HomePage from '@/views/HomePage.vue' import {HTTP} from '@/utils'; import {useUserStore} from "@/store/user"; +const lazy = (loader) => () => loader().catch((error) => { + const message = error?.message || ''; + if ( + message.includes('Failed to fetch dynamically imported module') || + message.includes('Importing a module script failed') || + message.includes('error loading dynamically imported module') + ) { + window.location.reload(); + } + throw error; +}); + const routes = [ { path: '/', @@ -12,136 +24,132 @@ const routes = [ { path: '/signup/:email?', name: 'signup', - // route level code-splitting - // this generates a separate chunk (about.[hash].js) for this route - // which is lazy-loaded when the route is visited. - component: () => import('@/views/SignUp.vue'), + component: lazy(() => import('@/views/SignUp.vue')), }, { path: '/custom-setup/:domain', name: 'customSetup', - component: () => import('@/views/DomainSetupPage.vue'), + component: lazy(() => import('@/views/DomainSetupPage.vue')), props: true, }, { path: '/dyn-setup/:domain', alias: '/dynsetup/:domain', name: 'dynSetup', - component: () => import('@/views/DynSetup.vue'), + component: lazy(() => import('@/views/DynSetup.vue')), }, { path: '/welcome/:domain?', name: 'welcome', - component: () => import('@/views/WelcomePage.vue'), + component: lazy(() => import('@/views/WelcomePage.vue')), }, { - path: 'https://desec.readthedocs.io/', + path: '/docs', name: 'docs', - beforeEnter(to) { location.href = to.path }, + beforeEnter() { location.href = 'https://desec.readthedocs.io/' }, }, { - path: 'https://talk.desec.io/', + path: '/talk', name: 'talk', - beforeEnter(to) { location.href = to.path }, + beforeEnter() { location.href = 'https://talk.desec.io/' }, }, { path: '/confirm/:action/:code', name: 'confirmation', - component: () => import('@/views/ConfirmationPage.vue') + component: lazy(() => import('@/views/ConfirmationPage.vue')) }, { path: '/reset-password/:email?', name: 'reset-password', - component: () => import('@/views/ResetPassword.vue'), + component: lazy(() => import('@/views/ResetPassword.vue')), }, { path: '/totp/', name: 'totp', - component: () => import('@/views/CrudListTOTP.vue'), + component: lazy(() => import('@/views/CrudListTOTP.vue')), meta: {guest: false}, }, { path: '/totp-verify/', name: 'TOTPVerify', - component: () => import('@/views/Console/TOTPVerifyDialog.vue'), + component: lazy(() => import('@/views/Console/TOTPVerifyDialog.vue')), props: (route) => ({...route.params}), }, { path: '/mfa/', name: 'mfa', - component: () => import('@/views/MFA.vue'), + component: lazy(() => import('@/views/MFA.vue')), meta: {guest: false}, }, { path: '/change-email/:email?', name: 'change-email', - component: () => import('@/views/ChangeEmail.vue'), + component: lazy(() => import('@/views/ChangeEmail.vue')), meta: {guest: false}, }, { path: '/delete-account/', name: 'delete-account', - component: () => import('@/views/DeleteAccount.vue'), + component: lazy(() => import('@/views/DeleteAccount.vue')), meta: {guest: false}, }, { path: '/donate/', name: 'donate', - component: () => import('@/views/DonatePage.vue'), + component: lazy(() => import('@/views/DonatePage.vue')), }, { - path: 'https://github.com/desec-io/desec-stack/milestones?direction=asc&sort=title&state=open', + path: '/roadmap', name: 'roadmap', - beforeEnter(to) { location.href = to.path }, + beforeEnter() { location.href = 'https://github.com/desec-io/desec-stack/milestones?direction=asc&sort=title&state=open' }, }, { path: '/impressum/', name: 'impressum', - component: () => import('@/views/ImpressumPage.vue'), + component: lazy(() => import('@/views/ImpressumPage.vue')), }, { path: '/privacy-policy/', name: 'privacy-policy', - component: () => import('@/views/PrivacyPolicy.vue'), + component: lazy(() => import('@/views/PrivacyPolicy.vue')), }, { path: '/terms/', name: 'terms', - component: () => import('@/views/TermsPage.vue'), + component: lazy(() => import('@/views/TermsPage.vue')), }, { path: '/about/', name: 'about', - component: () => import('@/views/AboutPage.vue'), + component: lazy(() => import('@/views/AboutPage.vue')), }, { path: '/login', name: 'login', - component: () => import('@/views/LoginPage.vue'), + component: lazy(() => import('@/views/LoginPage.vue')), }, { path: '/tokens', name: 'tokens', - component: () => import('@/views/CrudListToken.vue'), + component: lazy(() => import('@/views/CrudListToken.vue')), meta: {guest: false}, }, { path: '/domains', name: 'domains', - component: () => import('@/views/CrudListDomain.vue'), + component: lazy(() => import('@/views/CrudListDomain.vue')), meta: {guest: false}, }, { path: '/domains/:domain', name: 'domain', - component: () => import('@/views/CrudListRecord.vue'), + component: lazy(() => import('@/views/CrudListRecord.vue')), meta: {guest: false}, }, ] -const router = new VueRouter({ - mode: 'history', - base: import.meta.env.BASE_URL, +const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), scrollBehavior (to, from) { // Skip if destination full path has query parameters and differs in no other way from previous if (from && Object.keys(to.query).length) { diff --git a/www/webapp/src/views/AboutPage.vue b/www/webapp/src/views/AboutPage.vue index dbf24088e..62bb9fb2c 100644 --- a/www/webapp/src/views/AboutPage.vue +++ b/www/webapp/src/views/AboutPage.vue @@ -26,7 +26,7 @@ - + diff --git a/www/webapp/src/views/ChangeEmail.vue b/www/webapp/src/views/ChangeEmail.vue index b275b2dc1..d6d7f018c 100644 --- a/www/webapp/src/views/ChangeEmail.vue +++ b/www/webapp/src/views/ChangeEmail.vue @@ -20,7 +20,6 @@ Change Account Email Address @@ -38,32 +37,30 @@ {{ actionName }} Confirmation @@ -41,7 +40,7 @@ If you like our service, please consider donating.

- Donate + Donate

@@ -127,11 +126,6 @@ this.errors.splice(0, this.errors.length); } }, - filters: { - replace: function (value, a, b) { - return value.replace(a, b) - } - }, }; diff --git a/www/webapp/src/views/Console/DomainSetupDialog.vue b/www/webapp/src/views/Console/DomainSetupDialog.vue index c112777df..f491a0acc 100644 --- a/www/webapp/src/views/Console/DomainSetupDialog.vue +++ b/www/webapp/src/views/Console/DomainSetupDialog.vue @@ -12,15 +12,13 @@ Setup Instructions for {{ domain }}
- - {{ mdiClose }} - + Your domain {{ domain }} has been successfully created! @@ -49,20 +47,21 @@ export default { type: Boolean, default: false, }, - }, - data: () => ({ - mdiClose, - value: { + modelValue: { type: Boolean, default: true, }, + }, + data: () => ({ + mdiClose, }), computed: { show: { get() { - return this.value + return this.modelValue }, set(value) { + this.$emit('update:modelValue', value) this.$emit('input', value) } } diff --git a/www/webapp/src/views/Console/TOTPVerifyDialog.vue b/www/webapp/src/views/Console/TOTPVerifyDialog.vue index 4290b6b76..3cb202055 100644 --- a/www/webapp/src/views/Console/TOTPVerifyDialog.vue +++ b/www/webapp/src/views/Console/TOTPVerifyDialog.vue @@ -8,43 +8,48 @@ > - -
- Verify TOTP: {{ name }} -
+ + + Verify TOTP: {{ displayName }} + - - {{ mdiClose }} - + + + - + {{ detail }} - + {{ successDetail }}

- {{ mdiCheck }} + Great! Continue to log in.

- +

- {{ mdiNumeric1Circle }} + Please scan the following QR code with an authenticator app (e.g. Google Authenticator).
This code is only displayed once.

- +

- {{ mdiNumeric2Circle }} + Enter the code displayed in the authenticator app to confirm and activate the token:

@@ -65,7 +70,7 @@ Want to know what's in the code? — It's your TOTP secret:
- {{ data.secret }}
+ {{ payload.secret }}

@@ -92,7 +97,7 @@ + + diff --git a/www/webapp/src/views/CrudListDomain.vue b/www/webapp/src/views/CrudListDomain.vue index 9152497a3..1932acaf1 100644 --- a/www/webapp/src/views/CrudListDomain.vue +++ b/www/webapp/src/views/CrudListDomain.vue @@ -6,6 +6,7 @@ import {mdiDownload, mdiInformation} from "@mdi/js"; import GenericText from "@/components/Field/GenericText.vue"; import GenericTextarea from "@/components/Field/GenericTextarea.vue"; import TimeAgo from "@/components/Field/TimeAgo.vue"; +import DelegationStatus from "@/components/Field/DelegationStatus.vue"; export default { name: 'CrudListDomain', @@ -20,6 +21,8 @@ export default { updatable: false, destroyable: true, limit_domains: 0, + limit_insecure_domains: null, + insecure_delegated_domains: 0, headlines: { table: 'Domains', create: 'Create New Domain', @@ -27,8 +30,27 @@ export default { }, texts: { banner: () => 'To edit your DNS records, click on one of your domains.', - create: () => self.limit_domains != null ? `You have ${self.availableCount} of ${self.limit_domains} domains left.
Contact support to apply for a higher limit.` : '', - createWarning: () => (self.availableCount <= 0 ? 'You have reached your maximum number of domains. Please contact support to apply for a higher limit.' : ''), + create: () => { + if (self.limit_domains != null) { + return `You have ${self.availableCount} of ${self.limit_domains} domains left.
Contact support to apply for a higher limit.`; + } + if (self.limit_insecure_domains == null) { + return 'You can create multiple domains.'; + } + return `You can create multiple domains. You currently have ${self.insecure_delegated_domains} of ${self.limit_insecure_domains} domains without DNSSEC. Secure them before creating more.`; + }, + createWarning: () => { + if (self.availableCount <= 0 && self.limit_domains != null) { + return 'You have reached your maximum number of domains. Please contact support to apply for a higher limit.'; + } + if (self.limit_insecure_domains === 0) { + return 'Domain creation is disabled for your account. Please contact support if you need additional domains.'; + } + if (self.limit_insecure_domains != null && self.insecure_delegated_domains >= self.limit_insecure_domains) { + return 'You have reached your limit of domains without DNSSEC. Secure an existing domain first, then you can create more.'; + } + return ''; + }, destroy: d => (`Delete domain ${d.name}?`), destroyInfo: () => 'This operation will cause the domain to disappear from the DNS. It will no longer be reachable from the Internet.', }, @@ -56,6 +78,27 @@ export default { datatype: TimeAgo.name, searchable: false, }, + delegation_status: { + name: 'item.delegation_status', + text: 'Delegation Status', + align: 'left', + sortable: false, + value: 'delegation_checked', + readonly: true, + datatype: DelegationStatus.name, + searchable: false, + fieldProps: item => ({ item }), + }, + delegation_checked: { + name: 'item.delegation_checked', + text: 'Last Checked', + align: 'left', + sortable: true, + value: 'delegation_checked', + readonly: true, + datatype: TimeAgo.name, + searchable: false, + }, zonefile: { name: 'item.zonefile', text: 'Zonefile', @@ -139,14 +182,21 @@ export default { return this.limit_domains != null ? Math.max(this.limit_domains - this.rows.length, 0) : Infinity; }, createInhibited: function () { - return this.availableCount <= 0; + return this.availableCount <= 0 + || this.limit_insecure_domains === 0 + || (this.limit_insecure_domains != null + && this.insecure_delegated_domains >= this.limit_insecure_domains); }, }, async created() { const self = this; await withWorking(this.error, () => HTTP .get('auth/account/') - .then(r => self.limit_domains = r.data.limit_domains) + .then(r => { + self.limit_domains = r.data.limit_domains; + self.limit_insecure_domains = r.data.limit_insecure_domains ?? null; + self.insecure_delegated_domains = r.data.insecure_delegated_domains ?? 0; + }) ); }, }; From 655652c25f849087ecad63c362f02015ded0e428 Mon Sep 17 00:00:00 2001 From: Nils Wisiol Date: Sat, 10 Jan 2026 23:32:29 +0100 Subject: [PATCH 3/7] feat(api,webapp): allow triggering delegation check for domains --- api/api/settings.py | 3 + api/desecapi/delegation.py | 122 +++++++++++++++++ .../management/commands/check-delegation.py | 121 +--------------- .../0046_user_limit_insecure_domains.py | 21 +++ api/desecapi/tests/test_domains.py | 29 ++++ api/desecapi/views/domains.py | 27 ++++ docs/dns/domains.rst | 4 + docs/rate-limits.rst | 2 + .../views/Console/DelegationCheckDialog.vue | 129 ++++++++++++++++++ www/webapp/src/views/CrudListDomain.vue | 23 +++- 10 files changed, 363 insertions(+), 118 deletions(-) create mode 100644 api/desecapi/delegation.py create mode 100644 api/desecapi/migrations/0046_user_limit_insecure_domains.py create mode 100644 www/webapp/src/views/Console/DelegationCheckDialog.vue diff --git a/api/api/settings.py b/api/api/settings.py index b2b013d6b..94a360b37 100644 --- a/api/api/settings.py +++ b/api/api/settings.py @@ -118,6 +118,9 @@ "100/h", "300/d", ], # DNS API requests affecting RRset(s) of a single domain + "delegation_check": [ + "10/h", + ], # Manual delegation check per domain # UserRateThrottle "user": "2000/d", # hard limit on requests by a) an authenticated user, b) an unauthenticated IP address }, diff --git a/api/desecapi/delegation.py b/api/desecapi/delegation.py new file mode 100644 index 000000000..c7bcb1419 --- /dev/null +++ b/api/desecapi/delegation.py @@ -0,0 +1,122 @@ +from functools import cache +from socket import getaddrinfo + +from django.conf import settings +from django.utils import timezone +import dns.exception, dns.flags, dns.message, dns.name, dns.query, dns.resolver + + +SERVER = "8.8.8.8" +DNS_TIMEOUT = 5 + + +@cache +def lookup(target): + try: + addrinfo = getaddrinfo(str(target), None) + except OSError: + addrinfo = [] + return {v[-1][0] for v in addrinfo} + + +class DelegationChecker: + def __init__(self, udp_retries=2, server=SERVER): + self.udp_retries = udp_retries + self.server = server + self.our_ns_set = {dns.name.from_text(ns) for ns in settings.DEFAULT_NS} + self.our_ip_set = set.union(*(lookup(ns) for ns in self.our_ns_set)) + + def query_with_fallback(self, query): + if self.udp_retries <= 0: + return dns.query.tcp(query, self.server, timeout=DNS_TIMEOUT) + last_error = None + for _ in range(self.udp_retries): + try: + return dns.query.udp(query, self.server, timeout=DNS_TIMEOUT) + except Exception as ex: + last_error = ex + return dns.query.tcp(query, self.server, timeout=DNS_TIMEOUT) + + def resolve_with_fallback(self, resolver, name, rdtype): + if self.udp_retries <= 0: + return resolver.resolve(name, rdtype, tcp=True) + last_error = None + for _ in range(self.udp_retries): + try: + return resolver.resolve(name, rdtype, tcp=False) + except Exception as ex: + last_error = ex + return resolver.resolve(name, rdtype, tcp=True) + + def check_domain(self, domain): + # Identify parent + now = timezone.now() + domain_name = dns.name.from_text(domain.name) + parent = domain_name.parent() + resolver = dns.resolver.Resolver() + while len(parent): + query = dns.message.make_query(parent, dns.rdatatype.NS) + res = self.query_with_fallback(query) + if res.answer: + break + parent = parent.parent() + + # Find delegation NS hostnames and IP addresses + try: + ns = res.find_rrset(res.answer, parent, dns.rdataclass.IN, dns.rdatatype.NS) + except KeyError: + raise dns.resolver.NoNameservers + ipv4 = set() + ipv6 = set() + for rr in ns: + ipv4 |= {ip for ip in lookup(rr.target) if "." in ip} + ipv6 |= {ip for ip in lookup(rr.target) if "." not in ip} + + resolver.nameserver = list(ipv4) + list(ipv6) + try: + answer = self.resolve_with_fallback(resolver, domain_name, dns.rdatatype.NS) + except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN): + return { + "id": domain.id, + "delegation_checked": now, + "is_registered": False, + "has_all_nameservers": None, + "is_delegated": None, + "is_secured": None, + } + update = { + "id": domain.id, + "delegation_checked": now, + "is_registered": True, + } + + # Compute overlap of delegation NS hostnames and IP addresses with ours + ns_intersection = self.our_ns_set & {name.target for name in answer} + update["has_all_nameservers"] = ns_intersection == self.our_ns_set + + ns_ip_intersection = self.our_ip_set & set.union( + *(lookup(rr.target) for rr in answer) + ) + # .is_delegated: None means "not delegated to deSEC", False means "partial", True means "fully" + if not ns_ip_intersection: + update["is_delegated"] = None + else: + update["is_delegated"] = ns_ip_intersection == self.our_ip_set + + # Find delegation DS records and check validator-authenticated result + if ns_ip_intersection: + query = dns.message.make_query(domain_name, dns.rdatatype.DS) + res = self.query_with_fallback(query) + try: + res.find_rrset( + res.answer, domain_name, dns.rdataclass.IN, dns.rdatatype.DS + ) + has_ds = True + except KeyError: + has_ds = False + # AD bit indicates the resolver validated the DS answer. + authenticated = bool(res.flags & dns.flags.AD) + update["is_secured"] = bool(has_ds and authenticated) + else: + update["is_secured"] = None + return update diff --git a/api/desecapi/management/commands/check-delegation.py b/api/desecapi/management/commands/check-delegation.py index 5269e4ea5..e48e4eecf 100644 --- a/api/desecapi/management/commands/check-delegation.py +++ b/api/desecapi/management/commands/check-delegation.py @@ -1,42 +1,27 @@ from concurrent.futures import ThreadPoolExecutor, as_completed -from functools import cache -from socket import getaddrinfo import time from django.conf import settings from django.core.cache import cache as django_cache from django.core.management import BaseCommand, CommandError from django.db.models import Q -from django.utils import timezone -import dns.exception, dns.flags, dns.message, dns.name, dns.query, dns.resolver +import dns.exception, dns.resolver +from desecapi.delegation import DelegationChecker from desecapi.models import Domain -LPS = {dns.name.from_text(lps) for lps in settings.LOCAL_PUBLIC_SUFFIXES} -SERVER = "8.8.8.8" -DNS_TIMEOUT = 5 LOCK_KEY = "desecapi.check-delegation.lock" LOCK_TTL = 60 * 60 SAVE_BATCH_SIZE = 500 MAX_RUN_SECONDS = 60 * 60 -@cache -def lookup(target): - try: - addrinfo = getaddrinfo(str(target), None) - except OSError: - addrinfo = [] - return {v[-1][0] for v in addrinfo} - - class Command(BaseCommand): help = "Check delegation status." def __init__(self, *args, **kwargs): - self.our_ns_set = {dns.name.from_text(ns) for ns in settings.DEFAULT_NS} - self.our_ip_set = set.union(*(lookup(ns) for ns in self.our_ns_set)) + self.checker = DelegationChecker() super().__init__(*args, **kwargs) def add_arguments(self, parser): @@ -58,82 +43,8 @@ def add_arguments(self, parser): help="Number of worker threads to use.", ) - def handle_domain(self, domain): - # Identify parent - now = timezone.now() - domain_name = dns.name.from_text(domain.name) - parent = domain_name.parent() - udp_retries = self.udp_retries - resolver = dns.resolver.Resolver() - while len(parent): - query = dns.message.make_query(parent, dns.rdatatype.NS) - res = self.query_with_fallback(query, SERVER, udp_retries) - if res.answer: - break - parent = parent.parent() - - # Find delegation NS hostnames and IP addresses - try: - ns = res.find_rrset(res.answer, parent, dns.rdataclass.IN, dns.rdatatype.NS) - except KeyError: - raise dns.resolver.NoNameservers - ipv4 = set() - ipv6 = set() - for rr in ns: - ipv4 |= {ip for ip in lookup(rr.target) if "." in ip} - ipv6 |= {ip for ip in lookup(rr.target) if "." not in ip} - - resolver.nameserver = list(ipv4) + list(ipv6) - try: - answer = self.resolve_with_fallback(resolver, domain_name, dns.rdatatype.NS) - except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN): - return { - "id": domain.id, - "delegation_checked": now, - "is_registered": False, - "has_all_nameservers": None, - "is_delegated": None, - "is_secured": None, - } - update = { - "id": domain.id, - "delegation_checked": now, - "is_registered": True, - } - - # Compute overlap of delegation NS hostnames and IP addresses with ours - ns_intersection = self.our_ns_set & {name.target for name in answer} - update["has_all_nameservers"] = ns_intersection == self.our_ns_set - - ns_ip_intersection = self.our_ip_set & set.union( - *(lookup(rr.target) for rr in answer) - ) - # .is_delegated: None means "not delegated to deSEC", False means "partial", True means "fully" - if not ns_ip_intersection: - update["is_delegated"] = None - else: - update["is_delegated"] = ns_ip_intersection == self.our_ip_set - - # Find delegation DS records - if ns_ip_intersection: - query = dns.message.make_query(domain_name, dns.rdatatype.DS) - res = self.query_with_fallback(query, SERVER, udp_retries) - try: - res.find_rrset( - res.answer, domain_name, dns.rdataclass.IN, dns.rdatatype.DS - ) - has_ds = True - except KeyError: - has_ds = False - # AD bit indicates the resolver validated the DS answer. - authenticated = bool(res.flags & dns.flags.AD) - update["is_secured"] = bool(has_ds and authenticated) - else: - update["is_secured"] = None - return update - def run_check(self, options): - self.udp_retries = options["udp_retries"] + self.checker.udp_retries = options["udp_retries"] threads = options["threads"] qs = Domain.objects if options["domain-name"]: @@ -147,7 +58,7 @@ def run_check(self, options): def worker(domain): try: - update = self.handle_domain(domain) + update = self.checker.check_domain(domain) except (dns.exception.Timeout, dns.resolver.LifetimeTimeout): return ("timeout", domain, None) except dns.resolver.NoNameservers: @@ -216,25 +127,3 @@ def handle(self, *args, **options): finally: if lock_acquired: django_cache.delete(LOCK_KEY) - - def query_with_fallback(self, query, server, udp_retries): - if udp_retries <= 0: - return dns.query.tcp(query, server, timeout=DNS_TIMEOUT) - last_error = None - for _ in range(udp_retries): - try: - return dns.query.udp(query, server, timeout=DNS_TIMEOUT) - except Exception as ex: - last_error = ex - return dns.query.tcp(query, server, timeout=DNS_TIMEOUT) - - def resolve_with_fallback(self, resolver, name, rdtype): - if self.udp_retries <= 0: - return resolver.resolve(name, rdtype, tcp=True) - last_error = None - for _ in range(self.udp_retries): - try: - return resolver.resolve(name, rdtype, tcp=False) - except Exception as ex: - last_error = ex - return resolver.resolve(name, rdtype, tcp=True) diff --git a/api/desecapi/migrations/0046_user_limit_insecure_domains.py b/api/desecapi/migrations/0046_user_limit_insecure_domains.py new file mode 100644 index 000000000..a538b3687 --- /dev/null +++ b/api/desecapi/migrations/0046_user_limit_insecure_domains.py @@ -0,0 +1,21 @@ +from django.db import migrations, models + +import desecapi.models.users + + +class Migration(migrations.Migration): + dependencies = [ + ("desecapi", "0045_domain_delegation_status"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="limit_insecure_domains", + field=models.PositiveIntegerField( + blank=True, + default=desecapi.models.users.User._limit_insecure_domains_default, + null=True, + ), + ), + ] diff --git a/api/desecapi/tests/test_domains.py b/api/desecapi/tests/test_domains.py index 7f2496224..46d4e420a 100644 --- a/api/desecapi/tests/test_domains.py +++ b/api/desecapi/tests/test_domains.py @@ -1,9 +1,11 @@ from contextlib import nullcontext +from unittest.mock import Mock, patch from django.conf import settings from django.core import mail from django.core.exceptions import ValidationError from django.test import override_settings +from django.utils import timezone from rest_framework import status from desecapi.models import Domain @@ -271,6 +273,33 @@ def test_list_domains(self): self.assertTrue(my_domain_data["has_all_nameservers"]) self.assertFalse(my_domain_data["is_secured"]) + def test_delegation_check_endpoint(self): + url = ( + self.reverse("v1:domain-detail", name=self.my_domain.name) + + "delegation-check/" + ) + now = timezone.now() + update = { + "id": self.my_domain.id, + "delegation_checked": now, + "is_registered": True, + "has_all_nameservers": True, + "is_delegated": True, + "is_secured": True, + } + checker = Mock() + checker.check_domain.return_value = update + with patch("desecapi.views.domains.DelegationChecker", return_value=checker): + response = self.client.post(url) + self.assertStatus(response, status.HTTP_200_OK) + self.my_domain.refresh_from_db() + self.assertTrue(self.my_domain.is_registered) + self.assertTrue(self.my_domain.has_all_nameservers) + self.assertTrue(self.my_domain.is_delegated) + self.assertTrue(self.my_domain.is_secured) + self.assertIsNotNone(self.my_domain.delegation_checked) + self.assertEqual(response.data["is_registered"], True) + def test_list_domains_owns_qname(self): # Domains outside this account or non-existent for domain in ["non-existent.net", self.other_domain.name, "domain.invalid/"]: diff --git a/api/desecapi/views/domains.py b/api/desecapi/views/domains.py index 4de491439..1fb241db1 100644 --- a/api/desecapi/views/domains.py +++ b/api/desecapi/views/domains.py @@ -12,6 +12,7 @@ from rest_framework.views import APIView from desecapi import permissions +from desecapi.delegation import DelegationChecker from desecapi.models import Domain from desecapi.pdns import get_serials from desecapi.pdns_change_tracker import PDNSChangeTracker @@ -50,12 +51,16 @@ def permission_classes(self): ret.append(permissions.WithinInsecureDelegatedDomainLimit) case "destroy": ret.append(permissions.HasDeleteDomainPermission) + case "delegation_check": + pass case _: raise ValueError(f"Invalid action: {self.action}") return ret @property def throttle_scope(self): + if self.action == "delegation_check": + return "delegation_check" if self.action == "zonefile": self.throttle_scope_bucket = self.kwargs["name"] return "dns_api_per_domain_expensive" @@ -137,6 +142,28 @@ def zonefile(self, request, name=None): prefix = f"; Zonefile for {instance.name} exported from desec.{settings.DESECSTACK_DOMAIN} at {datetime.now(timezone.utc)}\n".encode() return Response(prefix + instance.zonefile, content_type="text/dns") + @action(detail=True, methods=["post"]) + def delegation_check(self, request, name=None): + instance = self.get_object() + checker = DelegationChecker() + update = checker.check_domain(instance) + instance.delegation_checked = update["delegation_checked"] + instance.is_registered = update["is_registered"] + instance.has_all_nameservers = update["has_all_nameservers"] + instance.is_delegated = update["is_delegated"] + instance.is_secured = update["is_secured"] + instance.save( + update_fields=[ + "delegation_checked", + "is_registered", + "has_all_nameservers", + "is_delegated", + "is_secured", + ] + ) + serializer = self.get_serializer(instance) + return Response(serializer.data) + class SerialListView(APIView): permission_classes = (permissions.IsVPNClient,) diff --git a/docs/dns/domains.rst b/docs/dns/domains.rst index db585df63..4b00d768d 100644 --- a/docs/dns/domains.rst +++ b/docs/dns/domains.rst @@ -264,6 +264,10 @@ If you need details about a domain's current status, request the domain via ``GET /api/v1/domains/{name}/`` or list domains via ``GET /api/v1/domains/`` and inspect the delegation fields. +To trigger a manual refresh for a single domain, issue a ``POST`` request to +``/api/v1/domains/{name}/delegation-check/``. The response contains the updated +domain object, including the delegation fields. This endpoint is rate-limited. + Retrieving a Specific Domain ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/rate-limits.rst b/docs/rate-limits.rst index a345ecb21..1ded2b5c3 100644 --- a/docs/rate-limits.rst +++ b/docs/rate-limits.rst @@ -56,6 +56,8 @@ the API. When several rates are given, all are enforced at the same time. | | | | | | 300/day | | +-----------------------------------------+----------+-------------------------------------------------------------------------------------------+ +| ``delegation_check`` | 10/h | Manual delegation check for a specific domain | ++-----------------------------------------+----------+-------------------------------------------------------------------------------------------+ | ``user`` | 2000/day | Any activity of a) authenticated users, b) unauthenticated users (by IP) | +-----------------------------------------+----------+-------------------------------------------------------------------------------------------+ diff --git a/www/webapp/src/views/Console/DelegationCheckDialog.vue b/www/webapp/src/views/Console/DelegationCheckDialog.vue new file mode 100644 index 000000000..a116543a0 --- /dev/null +++ b/www/webapp/src/views/Console/DelegationCheckDialog.vue @@ -0,0 +1,129 @@ + + + + + diff --git a/www/webapp/src/views/CrudListDomain.vue b/www/webapp/src/views/CrudListDomain.vue index 1932acaf1..db655eaf3 100644 --- a/www/webapp/src/views/CrudListDomain.vue +++ b/www/webapp/src/views/CrudListDomain.vue @@ -2,17 +2,19 @@ import { HTTP, withWorking } from '@/utils'; import CrudList from './CrudList.vue'; import DomainSetupDialog from '@/views/Console/DomainSetupDialog.vue'; -import {mdiDownload, mdiInformation} from "@mdi/js"; +import {mdiDownload, mdiInformation, mdiRefresh} from "@mdi/js"; import GenericText from "@/components/Field/GenericText.vue"; import GenericTextarea from "@/components/Field/GenericTextarea.vue"; import TimeAgo from "@/components/Field/TimeAgo.vue"; import DelegationStatus from "@/components/Field/DelegationStatus.vue"; +import DelegationCheckDialog from "@/views/Console/DelegationCheckDialog.vue"; export default { name: 'CrudListDomain', extends: CrudList, components: { DomainSetupDialog, + DelegationCheckDialog, }, data() { const self = this; @@ -118,6 +120,7 @@ export default { create: 'domains/', delete: 'domains/:{name}/', export: 'domains/:{name}/zonefile/', + delegationCheck: 'domains/:{name}/delegation-check/', }, itemDefaults: () => ({ name: '' }), postcreate: d => { @@ -157,19 +160,35 @@ export default { this.extraComponentBind = {'domain': d.name, 'ds': ds, 'dnskey': dnskey, 'is-new': isNew}; this.extraComponentName = 'DomainSetupDialog'; }, + async runDelegationCheck(domain) { + const url = this.resourcePath(this.paths.delegationCheck, domain, ':'); + await withWorking(this.error, () => HTTP + .post(url) + .then(r => { + Object.assign(domain, r.data); + this.extraComponentBind = { domain }; + this.extraComponentName = 'DelegationCheckDialog'; + }) + ); + }, handleRowClick: (value) => { this.$router.push({name: 'domain', params: {domain: value.name}}); }, } }, computed: { - actions() { + actions() { return { 'info': { go: d => this.showDomainInfo(d), icon: mdiInformation, tooltip: 'Setup instructions', }, + 'delegation_check': { + go: d => this.runDelegationCheck(d), + icon: mdiRefresh, + tooltip: 'Check delegation status', + }, 'export': { go: d => this.exportDomain(d), icon: mdiDownload, From 72d2cc33037e36c534c0e18f47eb2b0e37201a90 Mon Sep 17 00:00:00 2001 From: Nils Wisiol Date: Sun, 11 Jan 2026 09:26:00 +0100 Subject: [PATCH 4/7] fixup: fix table headers after migration --- www/webapp/src/views/CrudList.vue | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/www/webapp/src/views/CrudList.vue b/www/webapp/src/views/CrudList.vue index 5c22f063b..a74299f38 100644 --- a/www/webapp/src/views/CrudList.vue +++ b/www/webapp/src/views/CrudList.vue @@ -482,13 +482,19 @@ export default { cols = cols.filter(col => !(col.advanced || false)); } cols = cols.filter(col => !(col.hideFromTable || false)); - cols.push({ + const normalizeHeader = (col) => ({ + ...col, + title: col.title ?? col.text, + key: col.key ?? col.value, + }); + cols = cols.map(normalizeHeader); + cols.push(normalizeHeader({ text: 'Actions', sortable: false, align: 'right', value: 'actions', width: '130px', - }); + })); return cols; // data table expects an array }, itemsPerPage() { From 58599ff22ede451c2aec4cf18da701519c986116 Mon Sep 17 00:00:00 2001 From: Nils Wisiol Date: Mon, 12 Jan 2026 19:47:19 +0100 Subject: [PATCH 5/7] fixup! feat(api,webapp): allow triggering delegation check for domains --- api/desecapi/urls/version_1.py | 5 + .../src/components/Field/DelegationStatus.vue | 26 +++- .../views/Console/DelegationCheckDialog.vue | 129 ------------------ .../src/views/Console/DomainSetupDialog.vue | 82 +++++++++-- www/webapp/src/views/CrudList.vue | 20 ++- www/webapp/src/views/CrudListDomain.vue | 26 +++- www/webapp/src/views/DomainSetup.vue | 54 ++++++-- 7 files changed, 174 insertions(+), 168 deletions(-) delete mode 100644 www/webapp/src/views/Console/DelegationCheckDialog.vue diff --git a/api/desecapi/urls/version_1.py b/api/desecapi/urls/version_1.py index 3ff1af835..38f6f9b00 100644 --- a/api/desecapi/urls/version_1.py +++ b/api/desecapi/urls/version_1.py @@ -53,6 +53,11 @@ path("", views.Root.as_view(), name="root"), # Domain and RRSet management path("domains/", include(domains_router.urls)), + path( + "domains//delegation-check/", + views.DomainViewSet.as_view({"post": "delegation_check"}), + name="domain-delegation-check", + ), path("domains//rrsets/", views.RRsetList.as_view(), name="rrsets"), path( "domains//rrsets/...//", diff --git a/www/webapp/src/components/Field/DelegationStatus.vue b/www/webapp/src/components/Field/DelegationStatus.vue index 00bf70990..fa808313d 100644 --- a/www/webapp/src/components/Field/DelegationStatus.vue +++ b/www/webapp/src/components/Field/DelegationStatus.vue @@ -1,5 +1,5 @@ + + diff --git a/www/webapp/src/views/CrudList.vue b/www/webapp/src/views/CrudList.vue index a74299f38..d3e3d1e26 100644 --- a/www/webapp/src/views/CrudList.vue +++ b/www/webapp/src/views/CrudList.vue @@ -202,6 +202,7 @@ v-bind="column.fieldProps ? column.fieldProps(itemFieldProps.item) : {}" @keyup="keyupHandler" @dirty="dirty.add(itemFieldProps.item); dirtyError.delete(itemFieldProps.item);" + @click="column.onClick ? column.onClick(itemFieldProps.item, $event) : null" />