+{/if}
diff --git a/web/src/lib/components/inputs/Resource.svelte b/web/src/lib/components/inputs/Resource.svelte
index f0836beb..0e3af6e1 100644
--- a/web/src/lib/components/inputs/Resource.svelte
+++ b/web/src/lib/components/inputs/Resource.svelte
@@ -31,6 +31,7 @@
import TableInput from "$lib/components/inputs/table.svelte";
import type { Field } from "$lib/model/custom_form.svelte";
import type { ServiceInfos } from "$lib/model/service_specs.svelte";
+ import type { HappydnsCheckerOptionDocumentation } from "$lib/api-base/types.gen";
const dispatch = createEventDispatcher();
@@ -41,7 +42,7 @@
noDecorate?: boolean;
readonly?: boolean;
showDescription?: boolean;
- specs?: Field | ServiceInfos;
+ specs?: Field | ServiceInfos | HappydnsCheckerOptionDocumentation;
type: string;
value: any;
}
diff --git a/web/src/lib/components/modals/RunCheckModal.svelte b/web/src/lib/components/modals/RunCheckModal.svelte
new file mode 100644
index 00000000..d3951beb
--- /dev/null
+++ b/web/src/lib/components/modals/RunCheckModal.svelte
@@ -0,0 +1,326 @@
+
+
+
+
+
+
+ {$t("checkers.run-check.title")}: {checkDisplayName}
+
+
+ {#if checkStatusPromise && scopedOptionsPromise}
+ {#await Promise.all([checkStatusPromise, scopedOptionsPromise])}
+
+
+
{$t("checkers.run-check.loading-options")}
+
+ {:then [status, _domainOpts]}
+ {@const rules = status.rules || []}
+ {@const activeRulesForOpts = rules.map(
+ (r: HappydnsCheckerDefinition | null, i: number) =>
+ activeRules[i] !== false ? r : null,
+ )}
+ {@const runOpts = [
+ ...(status.options?.runOpts || []),
+ ...activeRulesForOpts.flatMap((r: any) => r?.options?.runOpts || []),
+ ]}
+ {@const otherOpts = [
+ ...(status.options?.adminOpts || []),
+ ...(status.options?.userOpts || []),
+ ...(status.options?.domainOpts || []),
+ ...activeRulesForOpts.flatMap((r: any) => [
+ ...(r?.options?.adminOpts || []),
+ ...(r?.options?.userOpts || []),
+ ...(r?.options?.domainOpts || []),
+ ]),
+ ].filter((o: any) => o.id)}
+
+ {:catch error}
+
+
+ {$t("checkers.run-check.error-loading-options", { error: error.message })}
+
+ {/await}
+ {/if}
+
+
+
+
+
+
diff --git a/web/src/lib/translations.ts b/web/src/lib/translations.ts
index cd034c59..3794012a 100644
--- a/web/src/lib/translations.ts
+++ b/web/src/lib/translations.ts
@@ -46,6 +46,7 @@ interface Params {
countdown?: string;
error?: string;
options?: string;
+ key?: string;
// add more parameters that are used here
}
diff --git a/web/src/lib/utils/checkers.ts b/web/src/lib/utils/checkers.ts
index 5d8e25eb..7c91d0e9 100644
--- a/web/src/lib/utils/checkers.ts
+++ b/web/src/lib/utils/checkers.ts
@@ -124,11 +124,12 @@ export function downloadBlob(content: string, filename: string, mime: string) {
URL.revokeObjectURL(url);
}
-export function formatCheckDate(date: string | undefined): string {
+export function formatCheckDate(date: string | Date | undefined): string {
if (!date) return "";
try {
+ if (date instanceof Date) return date.toLocaleString();
return new Date(date).toLocaleString();
} catch {
- return date;
+ return String(date);
}
}
diff --git a/web/src/routes/checkers/+layout.ts b/web/src/routes/checkers/+layout.ts
new file mode 100644
index 00000000..f3b81ba1
--- /dev/null
+++ b/web/src/routes/checkers/+layout.ts
@@ -0,0 +1,31 @@
+// This file is part of the happyDomain (R) project.
+// Copyright (c) 2022-2026 happyDomain
+// Authors: Pierre-Olivier Mercier, et al.
+//
+// This program is offered under a commercial and under the AGPL license.
+// For commercial licensing, contact us at .
+//
+// For AGPL licensing:
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import { type Load } from "@sveltejs/kit";
+import { get } from "svelte/store";
+
+import { checkers, refreshCheckers } from "$lib/stores/checkers";
+
+export const load: Load = async ({ parent }) => {
+ if (get(checkers) === undefined) refreshCheckers();
+
+ return await parent();
+};
diff --git a/web/src/routes/checkers/+page.svelte b/web/src/routes/checkers/+page.svelte
new file mode 100644
index 00000000..9fd0962b
--- /dev/null
+++ b/web/src/routes/checkers/+page.svelte
@@ -0,0 +1,99 @@
+
+
+
+
+
+ {$t("checkers.title")} - happyDomain
+
+
+
+
+ {#if $checkers}
+ {$t("checkers.available-count", {
+ count: Object.keys($checkers).length,
+ })}
+ {/if}
+
+
+
+
diff --git a/web/src/lib/components/domains/Table.svelte b/web/src/lib/components/domains/Table.svelte
index 7357295b..fcef36ac 100644
--- a/web/src/lib/components/domains/Table.svelte
+++ b/web/src/lib/components/domains/Table.svelte
@@ -28,14 +28,14 @@
import { deleteDomain } from "$lib/api/domains";
import DomainTableRow from "$lib/components/domains/DomainTableRow.svelte";
- import type { Domain } from "$lib/model/domain";
+ import type { HappydnsDomainWithCheckStatus } from "$lib/api-base/types.gen";
import { refreshDomains } from "$lib/stores/domains";
import { providersSpecs, refreshProvidersSpecs } from "$lib/stores/providers";
import { t } from "$lib/translations";
interface Props {
class?: ClassValue;
- items: Array;
+ items: Array;
[key: string]: unknown;
}
@@ -43,7 +43,7 @@
if (!$providersSpecs) refreshProvidersSpecs();
- async function delDomain(event: Event, item: Domain) {
+ async function delDomain(event: Event, item: HappydnsDomainWithCheckStatus) {
event.stopPropagation();
if (!confirm($t("domains.alert.remove", { domain: item.domain }))) return;
diff --git a/web/src/lib/components/modals/DomainGroup.svelte b/web/src/lib/components/modals/DomainGroup.svelte
index 1ee90f14..3955ec93 100644
--- a/web/src/lib/components/modals/DomainGroup.svelte
+++ b/web/src/lib/components/modals/DomainGroup.svelte
@@ -120,7 +120,7 @@
changeGroup(event, domain.id, domain)}
+ onchange={(event) => changeGroup(event, domain.id, domain)}
>
{#each mygroups as group}
diff --git a/web/src/lib/components/pages/home/DomainListSection.svelte b/web/src/lib/components/pages/home/DomainListSection.svelte
index 62f912e9..c98543a0 100644
--- a/web/src/lib/components/pages/home/DomainListSection.svelte
+++ b/web/src/lib/components/pages/home/DomainListSection.svelte
@@ -22,21 +22,24 @@
-->
-{#snippet domainRow(domain: ZoneListDomain)}
+{#snippet domainRow(domain: T)}
{#if badges}{@render badges({ domain })}{:else}
OK
diff --git a/web/src/lib/stores/domains.ts b/web/src/lib/stores/domains.ts
index da89ceb4..19296e9d 100644
--- a/web/src/lib/stores/domains.ts
+++ b/web/src/lib/stores/domains.ts
@@ -21,9 +21,9 @@
import { get, derived, writable, type Writable } from "svelte/store";
import { listDomains } from "$lib/api/domains";
-import type { Domain } from "$lib/model/domain";
+import type { HappydnsDomainWithCheckStatus } from "$lib/api-base/types.gen";
-export const domains: Writable | undefined> = writable(undefined);
+export const domains: Writable | undefined> = writable(undefined);
export const newlyGroups: Writable> = writable([]);
export async function refreshDomains() {
@@ -35,7 +35,7 @@ export async function refreshDomains() {
return data;
}
-export const groups = derived(domains, ($domains: Array | undefined) => {
+export const groups = derived(domains, ($domains: Array | undefined) => {
if (!$domains) return [];
const groups = new Set();
@@ -51,8 +51,8 @@ export const groups = derived(domains, ($domains: Array | undefined) =>
});
});
-export const domains_idx = derived(domains, ($domains: Array | undefined) => {
- const idx: Record = {};
+export const domains_idx = derived(domains, ($domains: Array | undefined) => {
+ const idx: Record = {};
if (!$domains) return idx;
@@ -75,8 +75,8 @@ export const domains_idx = derived(domains, ($domains: Array | undefined
return idx;
});
-export const domains_by_name = derived(domains, ($domains: Array | undefined) => {
- const idx: Record> = {};
+export const domains_by_name = derived(domains, ($domains: Array | undefined) => {
+ const idx: Record> = {};
if (!$domains) return idx;
@@ -91,8 +91,8 @@ export const domains_by_name = derived(domains, ($domains: Array | undef
return idx;
});
-export const domains_by_groups = derived(domains, ($domains: Array | undefined) => {
- const groups: Record> = {};
+export const domains_by_groups = derived(domains, ($domains: Array | undefined) => {
+ const groups: Record> = {};
if ($domains === undefined) {
return groups;
diff --git a/web/src/routes/domains/[dn]/+layout.ts b/web/src/routes/domains/[dn]/+layout.ts
index a4d4777c..5a4b1db4 100644
--- a/web/src/routes/domains/[dn]/+layout.ts
+++ b/web/src/routes/domains/[dn]/+layout.ts
@@ -1,7 +1,7 @@
import { error, type Load } from "@sveltejs/kit";
import { get } from "svelte/store";
-import type { Domain } from "$lib/model/domain";
+import type { HappydnsDomainWithZoneMetadata } from "$lib/api-base/types.gen";
import { domains, domains_idx, refreshDomains } from "$lib/stores/domains";
export const load: Load = async ({ parent, params }) => {
@@ -15,7 +15,7 @@ export const load: Load = async ({ parent, params }) => {
});
}
- const domain: Domain | null = get(domains_idx)[params.dn];
+ const domain: HappydnsDomainWithZoneMetadata | null = get(domains_idx)[params.dn];
if (!domain) {
error(404, {
From c963b7b27de1a3738c43dd11c9b9e392fff6ec71 Mon Sep 17 00:00:00 2001
From: Pierre-Olivier Mercier
Date: Sun, 5 Apr 2026 10:16:59 +0700
Subject: [PATCH 41/54] checkers: add typed option extraction helpers
---
internal/checker/options.go | 42 +++++++++++++++++++++++++++++++++++++
1 file changed, 42 insertions(+)
create mode 100644 internal/checker/options.go
diff --git a/internal/checker/options.go b/internal/checker/options.go
new file mode 100644
index 00000000..e664856d
--- /dev/null
+++ b/internal/checker/options.go
@@ -0,0 +1,42 @@
+// This file is part of the happyDomain (R) project.
+// Copyright (c) 2020-2026 happyDomain
+// Authors: Pierre-Olivier Mercier, et al.
+//
+// This program is offered under a commercial and under the AGPL license.
+// For commercial licensing, contact us at .
+//
+// For AGPL licensing:
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package checker
+
+import (
+ sdk "git.happydns.org/checker-sdk-go/checker"
+)
+
+// The option helpers live in the Apache-2.0 licensed checker-sdk-go module
+// so that external checker plugins can use them without inheriting AGPL
+// constraints. They are re-exported here as wrappers for backwards
+// compatibility with the rest of the happyDomain codebase.
+
+// GetOption is a generic wrapper around sdk.GetOption.
+func GetOption[T any](options sdk.CheckerOptions, key string) (T, bool) {
+ return sdk.GetOption[T](options, key)
+}
+
+var (
+ GetFloatOption = sdk.GetFloatOption
+ GetIntOption = sdk.GetIntOption
+ GetBoolOption = sdk.GetBoolOption
+)
From 90a297c2a84601cd7449180979bf334b49f298b0 Mon Sep 17 00:00:00 2001
From: Pierre-Olivier Mercier
Date: Sun, 5 Apr 2026 10:55:56 +0700
Subject: [PATCH 42/54] checkers: add NoOverride field support for checker
options
Prevent more specific scopes from overriding option values locked at a
higher scope (e.g. admin). Includes defense-in-depth stripping on
Set/Add operations, merge-time preservation, and frontend filtering.
---
.../checker/checker_options_usecase_test.go | 212 ++++++++++++++++++
.../checkers/CheckerOptionsPanel.svelte | 6 +-
.../components/modals/RunCheckModal.svelte | 6 +-
web/src/lib/utils/checkers.ts | 2 +-
4 files changed, 220 insertions(+), 6 deletions(-)
diff --git a/internal/usecase/checker/checker_options_usecase_test.go b/internal/usecase/checker/checker_options_usecase_test.go
index 5b48cd7f..59557892 100644
--- a/internal/usecase/checker/checker_options_usecase_test.go
+++ b/internal/usecase/checker/checker_options_usecase_test.go
@@ -1440,3 +1440,215 @@ func TestValidateOptions_SkipsAutoFillFields(t *testing.T) {
t.Fatalf("auto-fill required field should be skipped during validation, got: %v", err)
}
}
+
+// --- NoOverride tests ---
+
+func TestGetCheckerOptions_NoOverridePreservesAdminValue(t *testing.T) {
+ registerTestChecker("no_override_merge", &happydns.CheckerDefinition{
+ Options: happydns.CheckerOptionsDocumentation{
+ AdminOpts: []happydns.CheckerOptionDocumentation{
+ {Id: "locked", Type: "boolean", NoOverride: true},
+ },
+ UserOpts: []happydns.CheckerOptionDocumentation{
+ {Id: "threshold", Type: "number"},
+ },
+ },
+ })
+
+ store := newOptionsStore()
+ uc := checkerUC.NewCheckerOptionsUsecase(store, nil)
+
+ uid := idPtr()
+
+ // Set at admin scope.
+ store.UpdateCheckerConfiguration("no_override_merge", nil, nil, nil, happydns.CheckerOptions{
+ "locked": true,
+ })
+ // Attempt to override at user scope (should be ignored during merge).
+ store.UpdateCheckerConfiguration("no_override_merge", uid, nil, nil, happydns.CheckerOptions{
+ "locked": false,
+ "threshold": float64(42),
+ })
+
+ merged, err := uc.GetCheckerOptions("no_override_merge", uid, nil, nil)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if merged["locked"] != true {
+ t.Errorf("expected locked=true (admin value preserved), got %v", merged["locked"])
+ }
+ if merged["threshold"] != float64(42) {
+ t.Errorf("expected threshold=42 (user value applied), got %v", merged["threshold"])
+ }
+}
+
+func TestGetCheckerOptions_NoOverrideAllowsSameScope(t *testing.T) {
+ registerTestChecker("no_override_same_scope", &happydns.CheckerDefinition{
+ Options: happydns.CheckerOptionsDocumentation{
+ AdminOpts: []happydns.CheckerOptionDocumentation{
+ {Id: "locked", Type: "boolean", NoOverride: true},
+ },
+ },
+ })
+
+ store := newOptionsStore()
+ uc := checkerUC.NewCheckerOptionsUsecase(store, nil)
+
+ // Only admin scope sets the value — no conflict.
+ store.UpdateCheckerConfiguration("no_override_same_scope", nil, nil, nil, happydns.CheckerOptions{
+ "locked": true,
+ })
+
+ merged, err := uc.GetCheckerOptions("no_override_same_scope", nil, nil, nil)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if merged["locked"] != true {
+ t.Errorf("expected locked=true, got %v", merged["locked"])
+ }
+}
+
+func TestBuildMergedCheckerOptionsWithAutoFill_NoOverrideBlocksRunOpts(t *testing.T) {
+ registerTestChecker("no_override_runopt", &happydns.CheckerDefinition{
+ Options: happydns.CheckerOptionsDocumentation{
+ AdminOpts: []happydns.CheckerOptionDocumentation{
+ {Id: "locked", Type: "boolean", NoOverride: true},
+ },
+ UserOpts: []happydns.CheckerOptionDocumentation{
+ {Id: "threshold", Type: "number"},
+ },
+ },
+ })
+
+ store := newOptionsStore()
+ uc := checkerUC.NewCheckerOptionsUsecase(store, nil)
+
+ uid := idPtr()
+
+ // Admin sets locked=true.
+ store.UpdateCheckerConfiguration("no_override_runopt", nil, nil, nil, happydns.CheckerOptions{
+ "locked": true,
+ })
+ // User sets threshold.
+ store.UpdateCheckerConfiguration("no_override_runopt", uid, nil, nil, happydns.CheckerOptions{
+ "threshold": float64(10),
+ })
+
+ // RunOpts tries to override locked.
+ merged, err := uc.BuildMergedCheckerOptionsWithAutoFill("no_override_runopt", uid, nil, nil, happydns.CheckerOptions{
+ "locked": false,
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if merged["locked"] != true {
+ t.Errorf("expected locked=true (NoOverride should block runOpts), got %v", merged["locked"])
+ }
+ if merged["threshold"] != float64(10) {
+ t.Errorf("expected threshold=10, got %v", merged["threshold"])
+ }
+}
+
+func TestSetCheckerOptions_StripsNoOverrideAtLowerScope(t *testing.T) {
+ registerTestChecker("no_override_set", &happydns.CheckerDefinition{
+ Options: happydns.CheckerOptionsDocumentation{
+ AdminOpts: []happydns.CheckerOptionDocumentation{
+ {Id: "locked", Type: "boolean", NoOverride: true},
+ },
+ UserOpts: []happydns.CheckerOptionDocumentation{
+ {Id: "threshold", Type: "number"},
+ },
+ },
+ })
+
+ store := newOptionsStore()
+ uc := checkerUC.NewCheckerOptionsUsecase(store, nil)
+
+ uid := idPtr()
+
+ // Try to set locked at user scope — should be silently stripped.
+ err := uc.SetCheckerOptions("no_override_set", uid, nil, nil, happydns.CheckerOptions{
+ "locked": true,
+ "threshold": float64(99),
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ // Check what was actually stored.
+ stored := store.data[posKey("no_override_set", uid, nil, nil)]
+ if _, ok := stored["locked"]; ok {
+ t.Error("expected locked to be stripped from user-scope storage")
+ }
+ if stored["threshold"] != float64(99) {
+ t.Errorf("expected threshold=99 to be stored, got %v", stored["threshold"])
+ }
+}
+
+func TestAddCheckerOptions_StripsNoOverrideAtLowerScope(t *testing.T) {
+ registerTestChecker("no_override_add", &happydns.CheckerDefinition{
+ Options: happydns.CheckerOptionsDocumentation{
+ AdminOpts: []happydns.CheckerOptionDocumentation{
+ {Id: "locked", Type: "boolean", NoOverride: true},
+ },
+ UserOpts: []happydns.CheckerOptionDocumentation{
+ {Id: "threshold", Type: "number"},
+ },
+ },
+ })
+
+ store := newOptionsStore()
+ uc := checkerUC.NewCheckerOptionsUsecase(store, nil)
+
+ uid := idPtr()
+
+ // Pre-populate user scope with threshold.
+ store.UpdateCheckerConfiguration("no_override_add", uid, nil, nil, happydns.CheckerOptions{
+ "threshold": float64(50),
+ })
+
+ // Try to add locked at user scope — should be silently skipped.
+ result, err := uc.AddCheckerOptions("no_override_add", uid, nil, nil, happydns.CheckerOptions{
+ "locked": true,
+ "threshold": float64(75),
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if _, ok := result["locked"]; ok {
+ t.Error("expected locked to be skipped in AddCheckerOptions result")
+ }
+ if result["threshold"] != float64(75) {
+ t.Errorf("expected threshold=75, got %v", result["threshold"])
+ }
+}
+
+func TestSetCheckerOption_RejectsNoOverrideAtLowerScope(t *testing.T) {
+ registerTestChecker("no_override_set_single", &happydns.CheckerDefinition{
+ Options: happydns.CheckerOptionsDocumentation{
+ AdminOpts: []happydns.CheckerOptionDocumentation{
+ {Id: "locked", Type: "boolean", NoOverride: true},
+ },
+ },
+ })
+
+ store := newOptionsStore()
+ uc := checkerUC.NewCheckerOptionsUsecase(store, nil)
+
+ uid := idPtr()
+
+ // Setting at admin scope should work.
+ err := uc.SetCheckerOption("no_override_set_single", nil, nil, nil, "locked", true)
+ if err != nil {
+ t.Fatalf("expected SetCheckerOption at admin scope to succeed, got: %v", err)
+ }
+
+ // Setting at user scope should fail.
+ err = uc.SetCheckerOption("no_override_set_single", uid, nil, nil, "locked", false)
+ if err == nil {
+ t.Fatal("expected error when setting NoOverride field at lower scope")
+ }
+ if !strings.Contains(err.Error(), "cannot be overridden") {
+ t.Errorf("unexpected error message: %v", err)
+ }
+}
diff --git a/web/src/lib/components/checkers/CheckerOptionsPanel.svelte b/web/src/lib/components/checkers/CheckerOptionsPanel.svelte
index 1369588f..21a7bba8 100644
--- a/web/src/lib/components/checkers/CheckerOptionsPanel.svelte
+++ b/web/src/lib/components/checkers/CheckerOptionsPanel.svelte
@@ -75,11 +75,13 @@
onclean,
}: Props = $props();
- // Filter out auto-fill fields from editable groups (they are system-provided).
+ // Filter out auto-fill and noOverride fields from editable groups.
+ // Auto-fill fields are system-provided; noOverride fields can only be
+ // changed at the scope where they are defined (typically admin).
let filteredEditableGroups = $derived(
editableGroups.map((g) => ({
...g,
- opts: g.opts.filter((opt) => !opt.autoFill),
+ opts: g.opts.filter((opt) => !opt.autoFill && !opt.noOverride),
})),
);
diff --git a/web/src/lib/components/modals/RunCheckModal.svelte b/web/src/lib/components/modals/RunCheckModal.svelte
index d3951beb..8a87ae12 100644
--- a/web/src/lib/components/modals/RunCheckModal.svelte
+++ b/web/src/lib/components/modals/RunCheckModal.svelte
@@ -107,7 +107,7 @@
const ids = new Set();
if (!resolvedStatus) return ids;
const addOpts = (opts: HappydnsCheckerOptionDocumentation[] | undefined) =>
- opts?.forEach((o) => o.id && ids.add(o.id));
+ opts?.forEach((o) => o.id && !o.noOverride && ids.add(o.id));
addOpts(resolvedStatus.options?.runOpts);
addOpts(resolvedStatus.options?.adminOpts);
addOpts(resolvedStatus.options?.userOpts);
@@ -204,7 +204,7 @@
{@const runOpts = [
...(status.options?.runOpts || []),
...activeRulesForOpts.flatMap((r: any) => r?.options?.runOpts || []),
- ]}
+ ].filter((o: any) => !o.noOverride)}
{@const otherOpts = [
...(status.options?.adminOpts || []),
...(status.options?.userOpts || []),
@@ -214,7 +214,7 @@
...(r?.options?.userOpts || []),
...(r?.options?.domainOpts || []),
]),
- ].filter((o: any) => o.id)}
+ ].filter((o: any) => o.id && !o.noOverride)}
{
diff --git a/web/src/lib/utils/checkers.ts b/web/src/lib/utils/checkers.ts
index 7c91d0e9..f3c2de47 100644
--- a/web/src/lib/utils/checkers.ts
+++ b/web/src/lib/utils/checkers.ts
@@ -111,7 +111,7 @@ export function collectAllOptionDocs(
...(r.options?.userOpts || []),
...(r.options?.domainOpts || []),
]),
- ];
+ ].filter((o) => !o.noOverride);
}
export function downloadBlob(content: string, filename: string, mime: string) {
From b0dd2a5d21f4be1057418be183aa1e7f900fd97c Mon Sep 17 00:00:00 2001
From: Pierre-Olivier Mercier
Date: Sun, 5 Apr 2026 11:45:59 +0700
Subject: [PATCH 43/54] checkers: store observations as json.RawMessage with
cross-checker reuse
Refactor observation data pipeline to serialize once after collection and
keep json.RawMessage throughout storage and API responses. This eliminates
double-serialization and makes DB round-trips lossless.
---
internal/api/controller/checker_results.go | 11 +---
internal/app/app.go | 1 +
internal/storage/inmemory/checker.go | 26 ++++++++++
internal/storage/interface.go | 1 +
internal/storage/kvtpl/observation_cache.go | 45 ++++++++++++++++
.../usecase/checker/check_status_usecase.go | 4 +-
internal/usecase/checker/checker_engine.go | 51 +++++++++++++++++--
.../usecase/checker/checker_engine_test.go | 12 ++---
internal/usecase/checker/storage.go | 7 +++
9 files changed, 136 insertions(+), 22 deletions(-)
create mode 100644 internal/storage/kvtpl/observation_cache.go
diff --git a/internal/api/controller/checker_results.go b/internal/api/controller/checker_results.go
index 09205d10..a7c700c2 100644
--- a/internal/api/controller/checker_results.go
+++ b/internal/api/controller/checker_results.go
@@ -22,7 +22,6 @@
package controller
import (
- "encoding/json"
"fmt"
"net/http"
"strconv"
@@ -235,7 +234,7 @@ func (cc *CheckerController) GetExecutionObservation(c *gin.Context) {
return
}
- c.JSON(http.StatusOK, val)
+ c.Data(http.StatusOK, "application/json; charset=utf-8", val)
}
// GetExecutionResults returns the evaluation (per-rule states) for an execution.
@@ -380,13 +379,7 @@ func (cc *CheckerController) GetExecutionHTMLReport(c *gin.Context) {
return
}
- raw, err := json.Marshal(val)
- if err != nil {
- middleware.ErrorResponse(c, http.StatusInternalServerError, err)
- return
- }
-
- htmlContent, supported, err := checkerPkg.GetHTMLReport(obsKey, json.RawMessage(raw))
+ htmlContent, supported, err := checkerPkg.GetHTMLReport(obsKey, val)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
diff --git a/internal/app/app.go b/internal/app/app.go
index 54761a0d..2e98892f 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -269,6 +269,7 @@ func (app *App) initUsecases() {
app.store,
app.store,
app.store,
+ app.store,
)
app.usecases.checkerScheduler = checkerUC.NewScheduler(app.usecases.checkerEngine, app.cfg.CheckerMaxConcurrency, app.store, app.store, app.store, app.store)
app.usecases.checkerStatusUC.SetPlannedJobProvider(app.usecases.checkerScheduler)
diff --git a/internal/storage/inmemory/checker.go b/internal/storage/inmemory/checker.go
index 06914a06..e8dc2fff 100644
--- a/internal/storage/inmemory/checker.go
+++ b/internal/storage/inmemory/checker.go
@@ -579,6 +579,32 @@ func (s *InMemoryStorage) ClearSnapshots() error {
return nil
}
+// --- ObservationCacheStorage ---
+
+func obsCacheKey(target happydns.CheckTarget, key happydns.ObservationKey) string {
+ return fmt.Sprintf("obscache-%s-%s", target.String(), key)
+}
+
+func (s *InMemoryStorage) GetCachedObservation(target happydns.CheckTarget, key happydns.ObservationKey) (*happydns.ObservationCacheEntry, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ data, ok := s.data[obsCacheKey(target, key)]
+ if !ok {
+ return nil, happydns.ErrNotFound
+ }
+
+ entry := &happydns.ObservationCacheEntry{}
+ if err := s.DecodeData(data, entry); err != nil {
+ return nil, err
+ }
+ return entry, nil
+}
+
+func (s *InMemoryStorage) PutCachedObservation(target happydns.CheckTarget, key happydns.ObservationKey, entry *happydns.ObservationCacheEntry) error {
+ return s.Put(obsCacheKey(target, key), entry)
+}
+
// --- SchedulerStateStorage ---
func (s *InMemoryStorage) GetLastSchedulerRun() (time.Time, error) {
diff --git a/internal/storage/interface.go b/internal/storage/interface.go
index fbcb9245..6482cc53 100644
--- a/internal/storage/interface.go
+++ b/internal/storage/interface.go
@@ -45,6 +45,7 @@ type Storage interface {
checker.CheckerOptionsStorage
checker.CheckEvaluationStorage
checker.ExecutionStorage
+ checker.ObservationCacheStorage
checker.ObservationSnapshotStorage
checker.SchedulerStateStorage
domain.DomainStorage
diff --git a/internal/storage/kvtpl/observation_cache.go b/internal/storage/kvtpl/observation_cache.go
new file mode 100644
index 00000000..6772381c
--- /dev/null
+++ b/internal/storage/kvtpl/observation_cache.go
@@ -0,0 +1,45 @@
+// This file is part of the happyDomain (R) project.
+// Copyright (c) 2020-2026 happyDomain
+// Authors: Pierre-Olivier Mercier, et al.
+//
+// This program is offered under a commercial and under the AGPL license.
+// For commercial licensing, contact us at .
+//
+// For AGPL licensing:
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package database
+
+import (
+ "fmt"
+
+ "git.happydns.org/happyDomain/model"
+)
+
+func obsCacheKey(target happydns.CheckTarget, key happydns.ObservationKey) string {
+ return fmt.Sprintf("obscache-%s-%s", target.String(), key)
+}
+
+func (s *KVStorage) GetCachedObservation(target happydns.CheckTarget, key happydns.ObservationKey) (*happydns.ObservationCacheEntry, error) {
+ entry := &happydns.ObservationCacheEntry{}
+ err := s.db.Get(obsCacheKey(target, key), entry)
+ if err != nil {
+ return nil, err
+ }
+ return entry, nil
+}
+
+func (s *KVStorage) PutCachedObservation(target happydns.CheckTarget, key happydns.ObservationKey, entry *happydns.ObservationCacheEntry) error {
+ return s.db.Put(obsCacheKey(target, key), entry)
+}
diff --git a/internal/usecase/checker/check_status_usecase.go b/internal/usecase/checker/check_status_usecase.go
index 1e3e76f6..a31ac7d5 100644
--- a/internal/usecase/checker/check_status_usecase.go
+++ b/internal/usecase/checker/check_status_usecase.go
@@ -361,10 +361,10 @@ func (u *CheckStatusUsecase) GetSnapshotByExecution(execID happydns.Identifier,
return nil, err
}
- val, ok := snap.Data[obsKey]
+ raw, ok := snap.Data[obsKey]
if !ok {
return nil, happydns.ErrSnapshotNotFound
}
- return json.Marshal(val)
+ return raw, nil
}
diff --git a/internal/usecase/checker/checker_engine.go b/internal/usecase/checker/checker_engine.go
index e69a2a61..c5e7049f 100644
--- a/internal/usecase/checker/checker_engine.go
+++ b/internal/usecase/checker/checker_engine.go
@@ -23,6 +23,7 @@ package checker
import (
"context"
+ "encoding/json"
"fmt"
"log"
"time"
@@ -37,6 +38,7 @@ type checkerEngine struct {
evalStore CheckEvaluationStorage
execStore ExecutionStorage
snapStore ObservationSnapshotStorage
+ cacheStore ObservationCacheStorage
}
// NewCheckerEngine creates a new CheckerEngine implementation.
@@ -45,12 +47,14 @@ func NewCheckerEngine(
evalStore CheckEvaluationStorage,
execStore ExecutionStorage,
snapStore ObservationSnapshotStorage,
+ cacheStore ObservationCacheStorage,
) happydns.CheckerEngine {
return &checkerEngine{
- optionsUC: optionsUC,
- evalStore: evalStore,
- execStore: execStore,
- snapStore: snapStore,
+ optionsUC: optionsUC,
+ evalStore: evalStore,
+ execStore: execStore,
+ snapStore: snapStore,
+ cacheStore: cacheStore,
}
}
@@ -141,8 +145,35 @@ func (e *checkerEngine) runPipeline(ctx context.Context, def *happydns.CheckerDe
return happydns.CheckState{}, nil, fmt.Errorf("resolving options: %w", err)
}
+ // Build observation cache lookup for cross-checker reuse.
+ var cacheLookup checkerPkg.ObservationCacheLookup
+ if e.cacheStore != nil {
+ cacheLookup = func(target happydns.CheckTarget, key happydns.ObservationKey) (json.RawMessage, time.Time, error) {
+ entry, err := e.cacheStore.GetCachedObservation(target, key)
+ if err != nil {
+ return nil, time.Time{}, err
+ }
+ snap, err := e.snapStore.GetSnapshot(entry.SnapshotID)
+ if err != nil {
+ return nil, time.Time{}, err
+ }
+ raw, ok := snap.Data[key]
+ if !ok {
+ return nil, time.Time{}, fmt.Errorf("observation %q not in snapshot", key)
+ }
+ return raw, entry.CollectedAt, nil
+ }
+ }
+
+ var freshness time.Duration
+ if plan != nil && plan.Interval != nil {
+ freshness = *plan.Interval
+ } else if plan != nil && def.Interval != nil {
+ freshness = def.Interval.Default
+ }
+
// Create observation context for lazy data collection.
- obsCtx := checkerPkg.NewObservationContext(target, mergedOpts)
+ obsCtx := checkerPkg.NewObservationContext(target, mergedOpts, cacheLookup, freshness)
// Evaluate all rules, skipping disabled ones.
states := make([]happydns.CheckState, 0, len(def.Rules))
@@ -174,6 +205,16 @@ func (e *checkerEngine) runPipeline(ctx context.Context, def *happydns.CheckerDe
return happydns.CheckState{}, nil, fmt.Errorf("creating snapshot: %w", err)
}
+ // Update observation cache pointers for cross-checker reuse.
+ if e.cacheStore != nil {
+ for key := range snap.Data {
+ _ = e.cacheStore.PutCachedObservation(target, key, &happydns.ObservationCacheEntry{
+ SnapshotID: snap.Id,
+ CollectedAt: snap.CollectedAt,
+ })
+ }
+ }
+
// Persist evaluation.
eval := &happydns.CheckEvaluation{
PlanID: planID,
diff --git a/internal/usecase/checker/checker_engine_test.go b/internal/usecase/checker/checker_engine_test.go
index 777614ac..4c855a52 100644
--- a/internal/usecase/checker/checker_engine_test.go
+++ b/internal/usecase/checker/checker_engine_test.go
@@ -52,8 +52,8 @@ func (r *testCheckRule) Name() string { return r.name }
func (r *testCheckRule) Description() string { return "test rule: " + r.name }
func (r *testCheckRule) Evaluate(ctx context.Context, obs happydns.ObservationGetter, opts happydns.CheckerOptions) happydns.CheckState {
- _, err := obs.Get(ctx, "test_obs")
- if err != nil {
+ var data map[string]any
+ if err := obs.Get(ctx, "test_obs", &data); err != nil {
return happydns.CheckState{Status: happydns.StatusError, Message: err.Error()}
}
return happydns.CheckState{Status: r.status, Message: r.name + " passed", Code: r.name}
@@ -79,7 +79,7 @@ func TestCheckerEngine_RunOK(t *testing.T) {
})
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
- engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store)
+ engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store, store)
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
@@ -143,7 +143,7 @@ func TestCheckerEngine_RunWarn(t *testing.T) {
})
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
- engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store)
+ engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store, store)
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
@@ -188,7 +188,7 @@ func TestCheckerEngine_RunPerRuleDisable(t *testing.T) {
})
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
- engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store)
+ engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store, store)
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
@@ -278,7 +278,7 @@ func TestCheckerEngine_RunNotFound(t *testing.T) {
t.Fatalf("NewInMemoryStorage() returned error: %v", err)
}
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
- engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store)
+ engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store, store)
uid, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String()}
diff --git a/internal/usecase/checker/storage.go b/internal/usecase/checker/storage.go
index 0b67ac7f..7b07c5ab 100644
--- a/internal/usecase/checker/storage.go
+++ b/internal/usecase/checker/storage.go
@@ -112,3 +112,10 @@ type ObservationSnapshotStorage interface {
DeleteSnapshot(snapID happydns.Identifier) error
ClearSnapshots() error
}
+
+// ObservationCacheStorage provides a lightweight cache mapping (target, observation key)
+// to the snapshot that holds the most recent data.
+type ObservationCacheStorage interface {
+ GetCachedObservation(target happydns.CheckTarget, key happydns.ObservationKey) (*happydns.ObservationCacheEntry, error)
+ PutCachedObservation(target happydns.CheckTarget, key happydns.ObservationKey, entry *happydns.ObservationCacheEntry) error
+}
From e03563cff649b1a810af7c65f0ccee71145ca20a Mon Sep 17 00:00:00 2001
From: Pierre-Olivier Mercier
Date: Sun, 5 Apr 2026 12:09:07 +0700
Subject: [PATCH 44/54] checkers: add incremental scheduler updates on
domain/zone changes
Instead of rebuilding the entire scheduler queue, incrementally add or
remove jobs when domains are created/deleted or zones are
imported/published. A wake channel interrupts the run loop so new jobs
are picked up immediately. A jobKeys index prevents duplicate entries.
Hook points: domain creation, domain deletion, zone import, and zone
publish (correction apply) all notify the scheduler via the narrow
SchedulerDomainNotifier interface, wired through setter methods to
avoid initialization ordering issues.
---
internal/app/app.go | 4 +
internal/usecase/checker/scheduler.go | 138 ++++++++++++++++++
internal/usecase/checker/storage.go | 7 +
internal/usecase/domain/domain.go | 32 +++-
internal/usecase/orchestrator/factory.go | 13 ++
.../orchestrator/remote_zone_importer.go | 13 +-
.../orchestrator/zone_correction_applier.go | 19 ++-
7 files changed, 210 insertions(+), 16 deletions(-)
diff --git a/internal/app/app.go b/internal/app/app.go
index 2e98892f..355d8309 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -273,6 +273,10 @@ func (app *App) initUsecases() {
)
app.usecases.checkerScheduler = checkerUC.NewScheduler(app.usecases.checkerEngine, app.cfg.CheckerMaxConcurrency, app.store, app.store, app.store, app.store)
app.usecases.checkerStatusUC.SetPlannedJobProvider(app.usecases.checkerScheduler)
+
+ // Wire scheduler notifications for incremental queue updates.
+ domainService.SetSchedulerNotifier(app.usecases.checkerScheduler)
+ app.usecases.orchestrator.SetSchedulerNotifier(app.usecases.checkerScheduler)
}
func (app *App) setupRouter() {
diff --git a/internal/usecase/checker/scheduler.go b/internal/usecase/checker/scheduler.go
index c72f697a..31af4afd 100644
--- a/internal/usecase/checker/scheduler.go
+++ b/internal/usecase/checker/scheduler.go
@@ -96,12 +96,14 @@ type SchedulerStatus struct {
// Scheduler manages periodic execution of checkers.
type Scheduler struct {
queue SchedulerQueue
+ jobKeys map[string]bool
engine happydns.CheckerEngine
planStore CheckPlanStorage
domainStore DomainLister
zoneStore ZoneGetter
stateStore SchedulerStateStorage
cancel context.CancelFunc
+ wake chan struct{}
mu sync.RWMutex
running bool
ctx context.Context
@@ -119,6 +121,8 @@ func NewScheduler(engine happydns.CheckerEngine, maxConcurrency int, planStore C
domainStore: domainStore,
zoneStore: zoneStore,
stateStore: stateStore,
+ jobKeys: make(map[string]bool),
+ wake: make(chan struct{}, 1),
maxConcurrency: maxConcurrency,
}
}
@@ -206,6 +210,8 @@ func (s *Scheduler) run(ctx context.Context) {
select {
case <-ctx.Done():
return
+ case <-s.wake:
+ continue
case <-time.After(1 * time.Minute):
s.mu.Lock()
s.buildQueue()
@@ -228,6 +234,9 @@ func (s *Scheduler) run(ctx context.Context) {
case <-ctx.Done():
timer.Stop()
return
+ case <-s.wake:
+ timer.Stop()
+ continue
case <-timer.C:
}
}
@@ -287,14 +296,17 @@ func (s *Scheduler) run(ctx context.Context) {
}
// Add jitter for next cycle.
job.NextRun = job.NextRun.Add(computeJitter(job.CheckerID, job.Target.String(), job.NextRun, job.Interval))
+ key := job.CheckerID + "|" + job.Target.String()
s.mu.Lock()
heap.Push(&s.queue, job)
+ s.jobKeys[key] = true
s.mu.Unlock()
}
}
func (s *Scheduler) buildQueue() {
s.queue = s.queue[:0]
+ s.jobKeys = make(map[string]bool)
var lastRun time.Time
if s.stateStore != nil {
@@ -370,6 +382,7 @@ func (s *Scheduler) buildQueue() {
job.PlanID = &plan.Id
}
heap.Push(&s.queue, job)
+ s.jobKeys[key] = true
}
// Service-level discovery: load the latest zone and match services.
@@ -403,12 +416,137 @@ func (s *Scheduler) buildQueue() {
job.PlanID = &plan.Id
}
heap.Push(&s.queue, job)
+ s.jobKeys[key] = true
}
}
}
}
}
+// NotifyDomainChange incrementally adds scheduler jobs for a domain
+// without rebuilding the entire queue. Call this after a domain is
+// created or its zone is imported/published.
+func (s *Scheduler) NotifyDomainChange(domain *happydns.Domain) {
+ checkers := checkerPkg.GetCheckers()
+
+ // Load plans relevant to this domain.
+ uid := domain.Owner
+ did := domain.Id
+ domainTarget := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
+
+ plans, err := s.planStore.ListCheckPlansByTarget(domainTarget)
+ if err != nil {
+ log.Printf("Scheduler: NotifyDomainChange: failed to load plans: %v", err)
+ }
+ disabledSet := make(map[string]bool)
+ planMap := make(map[string]*happydns.CheckPlan)
+ for _, p := range plans {
+ key := p.CheckerID + "|" + p.Target.String()
+ planMap[key] = p
+ if p.IsFullyDisabled() {
+ disabledSet[key] = true
+ }
+ }
+
+ var added int
+ s.mu.Lock()
+
+ for checkerID, def := range checkers {
+ if def.Availability.ApplyToDomain {
+ key := checkerID + "|" + domainTarget.String()
+ if s.jobKeys[key] || disabledSet[key] {
+ continue
+ }
+ plan := planMap[key]
+ interval := s.effectiveInterval(def, plan)
+ job := &SchedulerJob{
+ CheckerID: checkerID,
+ Target: domainTarget,
+ Interval: interval,
+ NextRun: time.Now().Add(computeJitter(checkerID, domainTarget.String(), time.Now(), interval)),
+ }
+ if plan != nil {
+ job.PlanID = &plan.Id
+ }
+ heap.Push(&s.queue, job)
+ s.jobKeys[key] = true
+ added++
+ }
+
+ if def.Availability.ApplyToService {
+ services := s.loadDomainServices(domain)
+ for _, svc := range services {
+ if len(def.Availability.LimitToServices) > 0 && !slices.Contains(def.Availability.LimitToServices, svc.Type) {
+ continue
+ }
+ sid := svc.Id
+ svcTarget := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String(), ServiceId: sid.String(), ServiceType: svc.Type}
+ key := checkerID + "|" + svcTarget.String()
+ if s.jobKeys[key] || disabledSet[key] {
+ continue
+ }
+ plan := planMap[key]
+ interval := s.effectiveInterval(def, plan)
+ job := &SchedulerJob{
+ CheckerID: checkerID,
+ Target: svcTarget,
+ Interval: interval,
+ NextRun: time.Now().Add(computeJitter(checkerID, svcTarget.String(), time.Now(), interval)),
+ }
+ if plan != nil {
+ job.PlanID = &plan.Id
+ }
+ heap.Push(&s.queue, job)
+ s.jobKeys[key] = true
+ added++
+ }
+ }
+ }
+
+ s.mu.Unlock()
+
+ if added > 0 {
+ log.Printf("Scheduler: NotifyDomainChange(%s): added %d jobs", domain.DomainName, added)
+ // Wake the run loop so it re-evaluates the queue head.
+ select {
+ case s.wake <- struct{}{}:
+ default:
+ }
+ }
+}
+
+// NotifyDomainRemoved removes all scheduler jobs for the given domain.
+func (s *Scheduler) NotifyDomainRemoved(domainID happydns.Identifier) {
+ s.mu.Lock()
+ n := 0
+ for i := 0; i < len(s.queue); {
+ job := s.queue[i]
+ if job.Target.DomainId == domainID.String() {
+ key := job.CheckerID + "|" + job.Target.String()
+ delete(s.jobKeys, key)
+ // Swap with last and shrink.
+ s.queue[i] = s.queue[len(s.queue)-1]
+ s.queue[len(s.queue)-1] = nil
+ s.queue = s.queue[:len(s.queue)-1]
+ n++
+ } else {
+ i++
+ }
+ }
+ if n > 0 {
+ heap.Init(&s.queue)
+ // Re-index after Init.
+ for i, job := range s.queue {
+ job.index = i
+ }
+ }
+ s.mu.Unlock()
+
+ if n > 0 {
+ log.Printf("Scheduler: NotifyDomainRemoved(%s): removed %d jobs", domainID, n)
+ }
+}
+
func (s *Scheduler) loadAllPlans() ([]*happydns.CheckPlan, error) {
iter, err := s.planStore.ListAllCheckPlans()
if err != nil {
diff --git a/internal/usecase/checker/storage.go b/internal/usecase/checker/storage.go
index 7b07c5ab..4402c713 100644
--- a/internal/usecase/checker/storage.go
+++ b/internal/usecase/checker/storage.go
@@ -43,6 +43,13 @@ type ZoneGetter interface {
GetZone(id happydns.Identifier) (*happydns.ZoneMessage, error)
}
+// SchedulerDomainNotifier is a narrow interface for notifying the scheduler
+// about domain changes so it can incrementally update its job queue.
+type SchedulerDomainNotifier interface {
+ NotifyDomainChange(domain *happydns.Domain)
+ NotifyDomainRemoved(domainID happydns.Identifier)
+}
+
// CheckAutoFillStorage provides access to domain, zone and user data
// needed to resolve auto-fill field values at execution time.
type CheckAutoFillStorage interface {
diff --git a/internal/usecase/domain/domain.go b/internal/usecase/domain/domain.go
index 92c73f21..98dca76e 100644
--- a/internal/usecase/domain/domain.go
+++ b/internal/usecase/domain/domain.go
@@ -41,12 +41,20 @@ type DomainExistenceTester interface {
TestDomainExistence(ctx context.Context, provider *happydns.Provider, name string) error
}
+// SchedulerDomainNotifier is an optional callback to notify the scheduler
+// about domain changes so it can incrementally update its job queue.
+type SchedulerDomainNotifier interface {
+ NotifyDomainChange(domain *happydns.Domain)
+ NotifyDomainRemoved(domainID happydns.Identifier)
+}
+
type Service struct {
- store DomainStorage
- providerService ProviderGetter
- getZone *zoneUC.GetZoneUsecase
- domainExistence DomainExistenceTester
- domainLogAppender domainLogUC.DomainLogAppender
+ store DomainStorage
+ providerService ProviderGetter
+ getZone *zoneUC.GetZoneUsecase
+ domainExistence DomainExistenceTester
+ domainLogAppender domainLogUC.DomainLogAppender
+ schedulerNotifier SchedulerDomainNotifier
}
func NewService(
@@ -65,6 +73,12 @@ func NewService(
}
}
+// SetSchedulerNotifier sets the optional scheduler notifier for incremental
+// queue updates on domain creation/deletion.
+func (s *Service) SetSchedulerNotifier(notifier SchedulerDomainNotifier) {
+ s.schedulerNotifier = notifier
+}
+
// CreateDomain creates a new domain for the given user.
func (s *Service) CreateDomain(ctx context.Context, user *happydns.User, uz *happydns.Domain) error {
uz, err := happydns.NewDomain(user, uz.DomainName, uz.ProviderId)
@@ -93,6 +107,10 @@ func (s *Service) CreateDomain(ctx context.Context, user *happydns.User, uz *hap
s.domainLogAppender.AppendDomainLog(uz, happydns.NewDomainLog(user, happydns.LOG_INFO, fmt.Sprintf("Domain name %s added.", uz.DomainName)))
}
+ if s.schedulerNotifier != nil {
+ s.schedulerNotifier.NotifyDomainChange(uz)
+ }
+
return nil
}
@@ -194,5 +212,9 @@ func (s *Service) DeleteDomain(domainID happydns.Identifier) error {
}
}
+ if s.schedulerNotifier != nil {
+ s.schedulerNotifier.NotifyDomainRemoved(domainID)
+ }
+
return nil
}
diff --git a/internal/usecase/orchestrator/factory.go b/internal/usecase/orchestrator/factory.go
index 8176246b..6303148c 100644
--- a/internal/usecase/orchestrator/factory.go
+++ b/internal/usecase/orchestrator/factory.go
@@ -54,6 +54,12 @@ type ZoneCorrector interface {
ListZoneCorrections(ctx context.Context, provider *happydns.Provider, domain *happydns.Domain, records []happydns.Record) ([]*happydns.Correction, int, error)
}
+// SchedulerDomainNotifier is an optional callback to notify the scheduler
+// about domain changes so it can incrementally update its job queue.
+type SchedulerDomainNotifier interface {
+ NotifyDomainChange(domain *happydns.Domain)
+}
+
// Orchestrator aggregates the use-cases that together implement the DNS zone
// lifecycle: importing zones from a provider, listing required corrections, and
// applying those corrections back to the provider.
@@ -91,3 +97,10 @@ func NewOrchestrator(
ZoneImporter: zoneImporter,
}
}
+
+// SetSchedulerNotifier sets the optional scheduler notifier on the
+// sub-usecases that create or publish zones.
+func (o *Orchestrator) SetSchedulerNotifier(notifier SchedulerDomainNotifier) {
+ o.RemoteZoneImporter.schedulerNotifier = notifier
+ o.ZoneCorrectionApplier.schedulerNotifier = notifier
+}
diff --git a/internal/usecase/orchestrator/remote_zone_importer.go b/internal/usecase/orchestrator/remote_zone_importer.go
index e305d4b9..e020d670 100644
--- a/internal/usecase/orchestrator/remote_zone_importer.go
+++ b/internal/usecase/orchestrator/remote_zone_importer.go
@@ -34,10 +34,11 @@ import (
// from the provider and delegates to ZoneImporterUsecase to persist them. It
// also appends a domain log entry on success.
type RemoteZoneImporterUsecase struct {
- appendDomainLog domainlogUC.DomainLogAppender
- providerService ProviderGetter
- zoneImporter happydns.ZoneImporterUsecase
- zoneRetriever ZoneRetriever
+ appendDomainLog domainlogUC.DomainLogAppender
+ providerService ProviderGetter
+ zoneImporter happydns.ZoneImporterUsecase
+ zoneRetriever ZoneRetriever
+ schedulerNotifier SchedulerDomainNotifier
}
// NewRemoteZoneImporterUsecase creates a RemoteZoneImporterUsecase wired to
@@ -79,5 +80,9 @@ func (uc *RemoteZoneImporterUsecase) Import(ctx context.Context, user *happydns.
log.Printf("unable to append domain log for %s: %s", domain.DomainName, err.Error())
}
+ if uc.schedulerNotifier != nil {
+ uc.schedulerNotifier.NotifyDomainChange(domain)
+ }
+
return myZone, nil
}
diff --git a/internal/usecase/orchestrator/zone_correction_applier.go b/internal/usecase/orchestrator/zone_correction_applier.go
index 7e8b94de..af1e1368 100644
--- a/internal/usecase/orchestrator/zone_correction_applier.go
+++ b/internal/usecase/orchestrator/zone_correction_applier.go
@@ -41,13 +41,14 @@ import (
// in the domain history. The WIP zone at ZoneHistory[0] is never modified.
type ZoneCorrectionApplierUsecase struct {
*ZoneCorrectionListerUsecase
- appendDomainLog domainlogUC.DomainLogAppender
- domainUpdater DomainUpdater
- zoneCreator *zoneUC.CreateZoneUsecase
- zoneGetter *zoneUC.GetZoneUsecase
- zoneRetriever ZoneRetriever
- zoneUpdater *zoneUC.UpdateZoneUsecase
- clock func() time.Time
+ appendDomainLog domainlogUC.DomainLogAppender
+ domainUpdater DomainUpdater
+ zoneCreator *zoneUC.CreateZoneUsecase
+ zoneGetter *zoneUC.GetZoneUsecase
+ zoneRetriever ZoneRetriever
+ zoneUpdater *zoneUC.UpdateZoneUsecase
+ schedulerNotifier SchedulerDomainNotifier
+ clock func() time.Time
}
// NewZoneCorrectionApplierUsecase creates a ZoneCorrectionApplierUsecase with
@@ -288,6 +289,10 @@ func (uc *ZoneCorrectionApplierUsecase) Apply(
log.Printf("%s: unable to update WIP zone propagation times: %s", domain.DomainName, updateErr)
}
+ if uc.schedulerNotifier != nil {
+ uc.schedulerNotifier.NotifyDomainChange(domain)
+ }
+
return snapshot, nil
}
From f3208c07a6ce03062b75c2d2857c6db95619dd2c Mon Sep 17 00:00:00 2001
From: Pierre-Olivier Mercier
Date: Sun, 5 Apr 2026 16:43:55 +0700
Subject: [PATCH 45/54] checkers: add HTTP transport layer
Introduce a transport abstraction so observation providers can run either
locally or be delegated to a remote HTTP endpoint. When an admin sets the
"endpoint" option, the engine substitutes the local provider with an
HTTPObservationProvider that POSTs to {endpoint}/collect.
---
internal/checker/provider_http.go | 105 ++++++++++++++++++
internal/usecase/checker/checker_engine.go | 7 ++
.../usecase/checker/checker_engine_test.go | 2 +-
3 files changed, 113 insertions(+), 1 deletion(-)
create mode 100644 internal/checker/provider_http.go
diff --git a/internal/checker/provider_http.go b/internal/checker/provider_http.go
new file mode 100644
index 00000000..4043a8cc
--- /dev/null
+++ b/internal/checker/provider_http.go
@@ -0,0 +1,105 @@
+// This file is part of the happyDomain (R) project.
+// Copyright (c) 2020-2026 happyDomain
+// Authors: Pierre-Olivier Mercier, et al.
+//
+// This program is offered under a commercial and under the AGPL license.
+// For commercial licensing, contact us at .
+//
+// For AGPL licensing:
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package checker
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+
+ "git.happydns.org/happyDomain/model"
+)
+
+// HTTPObservationProvider is an ObservationProvider that delegates data
+// collection to a remote HTTP endpoint via POST /collect.
+type HTTPObservationProvider struct {
+ observationKey happydns.ObservationKey
+ endpoint string // base URL without trailing slash
+}
+
+// NewHTTPObservationProvider creates a new HTTP-backed observation provider.
+// endpoint is the base URL of the remote checker (e.g. "http://checker-ping:8080").
+func NewHTTPObservationProvider(key happydns.ObservationKey, endpoint string) *HTTPObservationProvider {
+ return &HTTPObservationProvider{
+ observationKey: key,
+ endpoint: strings.TrimSuffix(endpoint, "/"),
+ }
+}
+
+// Key returns the observation key this provider handles.
+func (p *HTTPObservationProvider) Key() happydns.ObservationKey {
+ return p.observationKey
+}
+
+// Collect sends the observation request to the remote endpoint and returns
+// the raw JSON data. The returned value is a json.RawMessage which
+// ObservationContext.Get() will marshal without double-encoding.
+func (p *HTTPObservationProvider) Collect(ctx context.Context, opts happydns.CheckerOptions) (any, error) {
+ reqBody := happydns.ExternalCollectRequest{
+ Key: p.observationKey,
+ Options: opts,
+ }
+
+ body, err := json.Marshal(reqBody)
+ if err != nil {
+ return nil, fmt.Errorf("HTTP provider %s: failed to marshal request: %w", p.observationKey, err)
+ }
+
+ url := p.endpoint + "/collect"
+ httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
+ if err != nil {
+ return nil, fmt.Errorf("HTTP provider %s: failed to create request: %w", p.observationKey, err)
+ }
+ httpReq.Header.Set("Content-Type", "application/json")
+
+ resp, err := http.DefaultClient.Do(httpReq)
+ if err != nil {
+ return nil, fmt.Errorf("HTTP provider %s: request failed: %w", p.observationKey, err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ respBody, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("HTTP provider %s: endpoint returned status %d: %s", p.observationKey, resp.StatusCode, string(respBody))
+ }
+
+ var result happydns.ExternalCollectResponse
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return nil, fmt.Errorf("HTTP provider %s: failed to decode response: %w", p.observationKey, err)
+ }
+
+ if result.Error != "" {
+ return nil, fmt.Errorf("HTTP provider %s: remote error: %s", p.observationKey, result.Error)
+ }
+
+ if result.Data == nil {
+ return nil, fmt.Errorf("HTTP provider %s: remote returned empty data", p.observationKey)
+ }
+
+ // Return json.RawMessage directly — it implements json.Marshaler,
+ // so ObservationContext.Get() won't double-encode it.
+ return result.Data, nil
+}
diff --git a/internal/usecase/checker/checker_engine.go b/internal/usecase/checker/checker_engine.go
index c5e7049f..c3927957 100644
--- a/internal/usecase/checker/checker_engine.go
+++ b/internal/usecase/checker/checker_engine.go
@@ -175,6 +175,13 @@ func (e *checkerEngine) runPipeline(ctx context.Context, def *happydns.CheckerDe
// Create observation context for lazy data collection.
obsCtx := checkerPkg.NewObservationContext(target, mergedOpts, cacheLookup, freshness)
+ // If an endpoint is configured, override observation providers with HTTP transport.
+ if endpoint, ok := mergedOpts["endpoint"].(string); ok && endpoint != "" {
+ for _, key := range def.ObservationKeys {
+ obsCtx.SetProviderOverride(key, checkerPkg.NewHTTPObservationProvider(key, endpoint))
+ }
+ }
+
// Evaluate all rules, skipping disabled ones.
states := make([]happydns.CheckState, 0, len(def.Rules))
for _, rule := range def.Rules {
diff --git a/internal/usecase/checker/checker_engine_test.go b/internal/usecase/checker/checker_engine_test.go
index 4c855a52..8a93577e 100644
--- a/internal/usecase/checker/checker_engine_test.go
+++ b/internal/usecase/checker/checker_engine_test.go
@@ -38,7 +38,7 @@ func (p *testObservationProvider) Key() happydns.ObservationKey {
return "test_obs"
}
-func (p *testObservationProvider) Collect(ctx context.Context, target happydns.CheckTarget, opts happydns.CheckerOptions) (any, error) {
+func (p *testObservationProvider) Collect(ctx context.Context, opts happydns.CheckerOptions) (any, error) {
return map[string]any{"value": 42}, nil
}
From 3f8cc18761f86bbc8f39f98d43bc88c0833fa923 Mon Sep 17 00:00:00 2001
From: Pierre-Olivier Mercier
Date: Sun, 5 Apr 2026 11:47:08 +0700
Subject: [PATCH 46/54] New checker: ICMP ping checker with RTT and packet loss
metrics
---
checkers/ping.go | 32 ++++++++++++++++++++++++++++++++
go.mod | 2 ++
go.sum | 4 ++++
3 files changed, 38 insertions(+)
create mode 100644 checkers/ping.go
diff --git a/checkers/ping.go b/checkers/ping.go
new file mode 100644
index 00000000..c289d892
--- /dev/null
+++ b/checkers/ping.go
@@ -0,0 +1,32 @@
+// This file is part of the happyDomain (R) project.
+// Copyright (c) 2020-2026 happyDomain
+// Authors: Pierre-Olivier Mercier, et al.
+//
+// This program is offered under a commercial and under the AGPL license.
+// For commercial licensing, contact us at .
+//
+// For AGPL licensing:
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package checkers
+
+import (
+ ping "git.happydns.org/checker-ping/checker"
+ "git.happydns.org/happyDomain/internal/checker"
+)
+
+func init() {
+ checker.RegisterObservationProvider(ping.Provider())
+ checker.RegisterExternalizableChecker(ping.Definition())
+}
diff --git a/go.mod b/go.mod
index aba88388..5ee3a038 100644
--- a/go.mod
+++ b/go.mod
@@ -5,6 +5,7 @@ go 1.25.0
toolchain go1.26.2
require (
+ git.happydns.org/checker-ping v0.0.0-20260407194626-a2ebf17774fc
git.happydns.org/checker-sdk-go v0.2.0
github.com/StackExchange/dnscontrol/v4 v4.34.0
github.com/altcha-org/altcha-lib-go v1.0.0
@@ -180,6 +181,7 @@ require (
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/pquerna/otp v1.5.0 // indirect
+ github.com/prometheus-community/pro-bing v0.8.0 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
diff --git a/go.sum b/go.sum
index fc3ede06..f0284cf7 100644
--- a/go.sum
+++ b/go.sum
@@ -12,6 +12,8 @@ codeberg.org/miekg/dns v0.6.67/go.mod h1:58Y3ZTg6Z5ZEm/ZAAwHehbZfrD4u5mE4RByHoPE
codeberg.org/miekg/dns v0.6.73 h1:4aRD1k1THw49vpe1d+W3KO16adAGN8Raxdi0WGvvbrY=
codeberg.org/miekg/dns v0.6.73/go.mod h1:58Y3ZTg6Z5ZEm/ZAAwHehbZfrD4u5mE4RByHoPEMyKk=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
+git.happydns.org/checker-ping v0.0.0-20260407194626-a2ebf17774fc h1:jKEOx2NDbHHxjCy1fUkcn1RgpzOKbE+bGRsF+ITNigI=
+git.happydns.org/checker-ping v0.0.0-20260407194626-a2ebf17774fc/go.mod h1:wphWmslFhKcpWfJTrHdChv8DkhUP9jwis7V2jy7vOX0=
git.happydns.org/checker-sdk-go v0.2.0 h1:Hg0GTcoEUgrkiUevgtgJ0kK04CnDM2f7VtFQiz4MmFc=
git.happydns.org/checker-sdk-go v0.2.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28=
@@ -575,6 +577,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
+github.com/prometheus-community/pro-bing v0.8.0 h1:CEY/g1/AgERRDjxw5P32ikcOgmrSuXs7xon7ovx6mNc=
+github.com/prometheus-community/pro-bing v0.8.0/go.mod h1:Idyxz8raDO6TgkUN6ByiEGvWJNyQd40kN9ZUeho3lN0=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
From 3bd6ec9d834eadc422600fde45d5473257abe0b5 Mon Sep 17 00:00:00 2001
From: Pierre-Olivier Mercier
Date: Wed, 8 Apr 2026 03:38:00 +0700
Subject: [PATCH 47/54] New checker: zonemaster
---
checkers/zonemaster.go | 33 +++++++++++++++++++++++++++++++++
go.mod | 1 +
go.sum | 2 ++
3 files changed, 36 insertions(+)
create mode 100644 checkers/zonemaster.go
diff --git a/checkers/zonemaster.go b/checkers/zonemaster.go
new file mode 100644
index 00000000..d8c56d05
--- /dev/null
+++ b/checkers/zonemaster.go
@@ -0,0 +1,33 @@
+// This file is part of the happyDomain (R) project.
+// Copyright (c) 2020-2026 happyDomain
+// Authors: Pierre-Olivier Mercier, et al.
+//
+// This program is offered under a commercial and under the AGPL license.
+// For commercial licensing, contact us at .
+//
+// For AGPL licensing:
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package checkers
+
+import (
+ zonemaster "git.happydns.org/checker-zonemaster/checker"
+ "git.happydns.org/happyDomain/internal/checker"
+)
+
+func init() {
+ checker.RegisterObservationProvider(zonemaster.Provider())
+ // Not Externalizable checker as it already calls a HTTP API
+ checker.RegisterChecker(zonemaster.Definition())
+}
diff --git a/go.mod b/go.mod
index 5ee3a038..dbd19c4e 100644
--- a/go.mod
+++ b/go.mod
@@ -7,6 +7,7 @@ toolchain go1.26.2
require (
git.happydns.org/checker-ping v0.0.0-20260407194626-a2ebf17774fc
git.happydns.org/checker-sdk-go v0.2.0
+ git.happydns.org/checker-zonemaster v0.0.0-20260407202727-979757b5a8fc
github.com/StackExchange/dnscontrol/v4 v4.34.0
github.com/altcha-org/altcha-lib-go v1.0.0
github.com/coreos/go-oidc/v3 v3.18.0
diff --git a/go.sum b/go.sum
index f0284cf7..09445d20 100644
--- a/go.sum
+++ b/go.sum
@@ -16,6 +16,8 @@ git.happydns.org/checker-ping v0.0.0-20260407194626-a2ebf17774fc h1:jKEOx2NDbHHx
git.happydns.org/checker-ping v0.0.0-20260407194626-a2ebf17774fc/go.mod h1:wphWmslFhKcpWfJTrHdChv8DkhUP9jwis7V2jy7vOX0=
git.happydns.org/checker-sdk-go v0.2.0 h1:Hg0GTcoEUgrkiUevgtgJ0kK04CnDM2f7VtFQiz4MmFc=
git.happydns.org/checker-sdk-go v0.2.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
+git.happydns.org/checker-zonemaster v0.0.0-20260407202727-979757b5a8fc h1:y5xjoqLA/WztFWhEUifOwnJ6POjl+Udw6bWjzQ2afOw=
+git.happydns.org/checker-zonemaster v0.0.0-20260407202727-979757b5a8fc/go.mod h1:B1P23OMm82GfAtYw8vCbspc7qULsFA0u/tqR+SGAaNw=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
From d2d5db236b3a83fea0350755bf8651ba69a96cf1 Mon Sep 17 00:00:00 2001
From: Pierre-Olivier Mercier
Date: Wed, 8 Apr 2026 04:09:41 +0700
Subject: [PATCH 48/54] New checker: Matrix federation
---
checkers/matrix_federation.go | 33 +++++++++++++++++++++++++++++++++
go.mod | 1 +
go.sum | 2 ++
3 files changed, 36 insertions(+)
create mode 100644 checkers/matrix_federation.go
diff --git a/checkers/matrix_federation.go b/checkers/matrix_federation.go
new file mode 100644
index 00000000..b6175d1f
--- /dev/null
+++ b/checkers/matrix_federation.go
@@ -0,0 +1,33 @@
+// This file is part of the happyDomain (R) project.
+// Copyright (c) 2020-2026 happyDomain
+// Authors: Pierre-Olivier Mercier, et al.
+//
+// This program is offered under a commercial and under the AGPL license.
+// For commercial licensing, contact us at .
+//
+// For AGPL licensing:
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package checkers
+
+import (
+ matrix "git.happydns.org/checker-matrix/checker"
+ "git.happydns.org/happyDomain/internal/checker"
+)
+
+func init() {
+ checker.RegisterObservationProvider(matrix.Provider())
+ // Not Externalizable checker as it already calls a HTTP API
+ checker.RegisterChecker(matrix.Definition())
+}
diff --git a/go.mod b/go.mod
index dbd19c4e..cb1e68ef 100644
--- a/go.mod
+++ b/go.mod
@@ -5,6 +5,7 @@ go 1.25.0
toolchain go1.26.2
require (
+ git.happydns.org/checker-matrix v0.0.0-20260407211824-2bb91d33d489
git.happydns.org/checker-ping v0.0.0-20260407194626-a2ebf17774fc
git.happydns.org/checker-sdk-go v0.2.0
git.happydns.org/checker-zonemaster v0.0.0-20260407202727-979757b5a8fc
diff --git a/go.sum b/go.sum
index 09445d20..4daeaf63 100644
--- a/go.sum
+++ b/go.sum
@@ -12,6 +12,8 @@ codeberg.org/miekg/dns v0.6.67/go.mod h1:58Y3ZTg6Z5ZEm/ZAAwHehbZfrD4u5mE4RByHoPE
codeberg.org/miekg/dns v0.6.73 h1:4aRD1k1THw49vpe1d+W3KO16adAGN8Raxdi0WGvvbrY=
codeberg.org/miekg/dns v0.6.73/go.mod h1:58Y3ZTg6Z5ZEm/ZAAwHehbZfrD4u5mE4RByHoPEMyKk=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
+git.happydns.org/checker-matrix v0.0.0-20260407211824-2bb91d33d489 h1:pTGfGq88Dj4Y60LJLSW4FvpUubeYpNlwuxKt/2IFzdo=
+git.happydns.org/checker-matrix v0.0.0-20260407211824-2bb91d33d489/go.mod h1:fQjY1yWYFucu+Ebn5uYM7ZWTJNQIgjMENI/8tqlaR98=
git.happydns.org/checker-ping v0.0.0-20260407194626-a2ebf17774fc h1:jKEOx2NDbHHxjCy1fUkcn1RgpzOKbE+bGRsF+ITNigI=
git.happydns.org/checker-ping v0.0.0-20260407194626-a2ebf17774fc/go.mod h1:wphWmslFhKcpWfJTrHdChv8DkhUP9jwis7V2jy7vOX0=
git.happydns.org/checker-sdk-go v0.2.0 h1:Hg0GTcoEUgrkiUevgtgJ0kK04CnDM2f7VtFQiz4MmFc=
From caa86c37106f506f99eaa33816d9f1bc046b71d5 Mon Sep 17 00:00:00 2001
From: Pierre-Olivier Mercier
Date: Wed, 8 Apr 2026 11:46:32 +0700
Subject: [PATCH 49/54] model: add UserQuota struct for admin-controlled
per-user limits
Introduce a UserQuota field on the User model to hold admin-controlled
limits and flags that the user cannot modify. Only checker-related
fields are defined for now (max checks per day, retention days,
inactivity pause days, scheduling kill switch); future paid-plan
attributes will be added here later.
The user-facing API only exposes settings updates and account deletion,
so Quota cannot be written through it. Updates go through the existing
admin user PUT endpoint, with a new editor card in the admin UI under
/users/[uid].
---
model/user.go | 4 +
model/user_quota.go | 51 +++++
web-admin/src/routes/users/[uid]/+page.svelte | 4 +-
.../routes/users/[uid]/UserQuotaCard.svelte | 198 ++++++++++++++++++
4 files changed, 256 insertions(+), 1 deletion(-)
create mode 100644 model/user_quota.go
create mode 100644 web-admin/src/routes/users/[uid]/UserQuotaCard.svelte
diff --git a/model/user.go b/model/user.go
index aa8c64fe..2c5c8f46 100644
--- a/model/user.go
+++ b/model/user.go
@@ -42,6 +42,10 @@ type User struct {
// Settings holds the settings for an account.
Settings UserSettings `json:"settings" binding:"required"`
+
+ // Quota holds admin-controlled limits for the account. It is never
+ // writable through the user-facing API; only the admin API can update it.
+ Quota UserQuota `json:"quota"`
}
func (u *User) GetUserId() Identifier {
diff --git a/model/user_quota.go b/model/user_quota.go
new file mode 100644
index 00000000..df0f53b1
--- /dev/null
+++ b/model/user_quota.go
@@ -0,0 +1,51 @@
+// This file is part of the happyDomain (R) project.
+// Copyright (c) 2020-2026 happyDomain
+// Authors: Pierre-Olivier Mercier, et al.
+//
+// This program is offered under a commercial and under the AGPL license.
+// For commercial licensing, contact us at .
+//
+// For AGPL licensing:
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package happydns
+
+import "time"
+
+// UserQuota holds admin-controlled per-user limits and flags. These fields are
+// never modifiable by the user; they can only be updated through the admin API.
+//
+// Only checker-related fields are defined for now. Future paid-plan attributes
+// (plan tier, domain caps, payment metadata, ...) will be added here later.
+type UserQuota struct {
+ // MaxChecksPerDay caps the number of checker executions per day for this
+ // user. 0 means "use the system default".
+ MaxChecksPerDay int `json:"max_checks_per_day,omitempty"`
+
+ // RetentionDays is the maximum age (in days) of checker executions kept in
+ // storage for this user. 0 means "use the system default".
+ RetentionDays int `json:"retention_days,omitempty"`
+
+ // InactivityPauseDays is the number of days without login after which the
+ // scheduler stops running checks for this user. 0 means "use the system
+ // default". A negative value disables the inactivity pause for this user.
+ InactivityPauseDays int `json:"inactivity_pause_days,omitempty"`
+
+ // SchedulingPaused, when true, completely disables the scheduler for this
+ // user (admin kill switch).
+ SchedulingPaused bool `json:"scheduling_paused,omitempty"`
+
+ // UpdatedAt records the last time these quotas were modified.
+ UpdatedAt time.Time `json:"updated_at,omitzero" format:"date-time"`
+}
diff --git a/web-admin/src/routes/users/[uid]/+page.svelte b/web-admin/src/routes/users/[uid]/+page.svelte
index d88278fd..2fc2ce7c 100644
--- a/web-admin/src/routes/users/[uid]/+page.svelte
+++ b/web-admin/src/routes/users/[uid]/+page.svelte
@@ -27,6 +27,7 @@
import { getUsersByUid, getUsersByUidDomains, getUsersByUidProviders } from "$lib/api-admin";
import UserInfoCard from "./UserInfoCard.svelte";
+ import UserQuotaCard from "./UserQuotaCard.svelte";
import UserDomainsCard from "./domains/UserDomainsCard.svelte";
import UserProvidersCard from "./providers/UserProvidersCard.svelte";
@@ -55,8 +56,9 @@
{@const user = userR.data}
{#if user}
-
+
+
+
+ Admin kill switch — when enabled, no checks will run for this
+ user regardless of their plans.
+
+
+
+
+
+
+
+ Maximum age of stored check executions. Older entries are
+ pruned by the janitor according to the tiered retention policy.
+
+
+
+
+
+
+
+ Daily cap on the number of executions the scheduler may launch
+ for this user (enforced later).
+
+
+
+
+
+
+
+ The scheduler stops running checks after this many days
+ without login. Use a negative value to disable.
+
+
+
+
+ {#if loading}
+
+ {:else}
+
+ {/if}
+ Save Quota
+
+
+
+
From 573b1aa90a878b66b44b71879d1852657bdd2db0 Mon Sep 17 00:00:00 2001
From: Pierre-Olivier Mercier
Date: Wed, 8 Apr 2026 11:51:01 +0700
Subject: [PATCH 50/54] checker: add tiered RetentionPolicy
Introduce a pure RetentionPolicy.Decide function that partitions check
executions into keep/drop sets according to a tiered policy:
- 0..7 days -> every execution
- 7..30 days -> 2 per day per (checker, target)
- 30..D/2 -> 1 per week per (checker, target)
- D/2..D days -> 1 per month per (checker, target)
- > D days -> dropped
The function is intentionally storage-agnostic so the upcoming janitor
goroutine can call it on any execution slice and so it can be unit
tested directly. All thresholds are configurable to allow per-user
overrides via UserQuota.
---
internal/usecase/checker/retention.go | 183 +++++++++++++++++++++
internal/usecase/checker/retention_test.go | 128 ++++++++++++++
2 files changed, 311 insertions(+)
create mode 100644 internal/usecase/checker/retention.go
create mode 100644 internal/usecase/checker/retention_test.go
diff --git a/internal/usecase/checker/retention.go b/internal/usecase/checker/retention.go
new file mode 100644
index 00000000..1c67840b
--- /dev/null
+++ b/internal/usecase/checker/retention.go
@@ -0,0 +1,183 @@
+// This file is part of the happyDomain (R) project.
+// Copyright (c) 2020-2026 happyDomain
+// Authors: Pierre-Olivier Mercier, et al.
+//
+// This program is offered under a commercial and under the AGPL license.
+// For commercial licensing, contact us at .
+//
+// For AGPL licensing:
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package checker
+
+import (
+ "sort"
+ "time"
+
+ "git.happydns.org/happyDomain/model"
+)
+
+// RetentionPolicy describes how check executions are thinned out as they age.
+//
+// The policy is intentionally tiered: users care about full detail for recent
+// runs, but only need sparse historical samples to spot long-term trends.
+//
+// Default behaviour, given a RetentionDays of D:
+//
+// age window | kept
+// ------------------------- | ------------------------------------------
+// 0 .. 7 days | every execution
+// 7 .. 30 days | up to 2 executions per day per (checker,target)
+// 30 .. D/2 days | up to 1 execution per week per (checker,target)
+// D/2 .. D days | up to 1 execution per month per (checker,target)
+// > D days | dropped
+//
+// All thresholds and bucket counts are configurable so the policy can be
+// tuned per-user via the admin UserQuota.
+type RetentionPolicy struct {
+ // RetentionDays is the hard cap on age. Executions older than this are
+ // always dropped. Must be > 0.
+ RetentionDays int
+
+ // FullDetailDays: every execution kept under this age.
+ FullDetailDays int
+ // DailyBucketDays: between FullDetailDays and DailyBucketDays, keep
+ // PerDayKept executions per UTC day per (checker,target).
+ DailyBucketDays int
+ PerDayKept int
+ // WeeklyBucketDays: between DailyBucketDays and WeeklyBucketDays, keep
+ // PerWeekKept executions per ISO week per (checker,target).
+ WeeklyBucketDays int
+ PerWeekKept int
+ // Beyond WeeklyBucketDays and up to RetentionDays, keep PerMonthKept
+ // executions per calendar month per (checker,target).
+ PerMonthKept int
+}
+
+// DefaultRetentionPolicy returns the standard tiered policy for the given
+// retention horizon.
+func DefaultRetentionPolicy(retentionDays int) RetentionPolicy {
+ if retentionDays <= 0 {
+ retentionDays = 365
+ }
+ return RetentionPolicy{
+ RetentionDays: retentionDays,
+ FullDetailDays: 7,
+ DailyBucketDays: 30,
+ PerDayKept: 2,
+ WeeklyBucketDays: max(retentionDays/2, 31),
+ PerWeekKept: 1,
+ PerMonthKept: 1,
+ }
+}
+
+// Decide partitions executions into the ones to keep and the ones to drop
+// according to the policy. The function is pure: it does not touch storage.
+//
+// Executions are grouped by (CheckerID, Target) and ordered most-recent-first
+// inside each group, so the newest execution in a bucket is the one preserved.
+func (p RetentionPolicy) Decide(executions []*happydns.Execution, now time.Time) (keep, drop []happydns.Identifier) {
+ if len(executions) == 0 {
+ return nil, nil
+ }
+
+ // Group by (checker, target).
+ groups := map[string][]*happydns.Execution{}
+ for _, e := range executions {
+ if e == nil {
+ continue
+ }
+ key := e.CheckerID + "|" + e.Target.String()
+ groups[key] = append(groups[key], e)
+ }
+
+ hardCutoff := now.AddDate(0, 0, -p.RetentionDays)
+ fullCutoff := now.AddDate(0, 0, -p.FullDetailDays)
+ dailyCutoff := now.AddDate(0, 0, -p.DailyBucketDays)
+ weeklyCutoff := now.AddDate(0, 0, -p.WeeklyBucketDays)
+
+ for _, group := range groups {
+ // Most recent first.
+ sort.Slice(group, func(i, j int) bool {
+ return group[i].StartedAt.After(group[j].StartedAt)
+ })
+
+ dayBuckets := map[string]int{}
+ weekBuckets := map[string]int{}
+ monthBuckets := map[string]int{}
+
+ for _, e := range group {
+ t := e.StartedAt
+ switch {
+ case t.Before(hardCutoff):
+ drop = append(drop, e.Id)
+ case !t.Before(fullCutoff):
+ // 0 .. FullDetailDays — keep everything.
+ keep = append(keep, e.Id)
+ case !t.Before(dailyCutoff):
+ k := t.UTC().Format("2006-01-02")
+ if dayBuckets[k] < p.PerDayKept {
+ dayBuckets[k]++
+ keep = append(keep, e.Id)
+ } else {
+ drop = append(drop, e.Id)
+ }
+ case !t.Before(weeklyCutoff):
+ y, w := t.UTC().ISOWeek()
+ k := isoWeekKey(y, w)
+ if weekBuckets[k] < p.PerWeekKept {
+ weekBuckets[k]++
+ keep = append(keep, e.Id)
+ } else {
+ drop = append(drop, e.Id)
+ }
+ default:
+ k := t.UTC().Format("2006-01")
+ if monthBuckets[k] < p.PerMonthKept {
+ monthBuckets[k]++
+ keep = append(keep, e.Id)
+ } else {
+ drop = append(drop, e.Id)
+ }
+ }
+ }
+ }
+
+ return keep, drop
+}
+
+func isoWeekKey(year, week int) string {
+ return time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC).Format("2006") + "-W" + twoDigits(week)
+}
+
+func twoDigits(n int) string {
+ if n < 10 {
+ return "0" + itoa(n)
+ }
+ return itoa(n)
+}
+
+func itoa(n int) string {
+ if n == 0 {
+ return "0"
+ }
+ var buf [4]byte
+ i := len(buf)
+ for n > 0 {
+ i--
+ buf[i] = byte('0' + n%10)
+ n /= 10
+ }
+ return string(buf[i:])
+}
diff --git a/internal/usecase/checker/retention_test.go b/internal/usecase/checker/retention_test.go
new file mode 100644
index 00000000..7d1383b1
--- /dev/null
+++ b/internal/usecase/checker/retention_test.go
@@ -0,0 +1,128 @@
+// This file is part of the happyDomain (R) project.
+// Copyright (c) 2020-2026 happyDomain
+// Authors: Pierre-Olivier Mercier, et al.
+//
+// This program is offered under a commercial and under the AGPL license.
+// For commercial licensing, contact us at .
+//
+// For AGPL licensing:
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package checker
+
+import (
+ "fmt"
+ "testing"
+ "time"
+
+ "git.happydns.org/happyDomain/model"
+)
+
+func mkExec(id string, age time.Duration, now time.Time) *happydns.Execution {
+ return &happydns.Execution{
+ Id: happydns.Identifier(id),
+ CheckerID: "ping",
+ Target: happydns.CheckTarget{DomainId: "example.com"},
+ StartedAt: now.Add(-age),
+ }
+}
+
+func TestDecide_Empty(t *testing.T) {
+ p := DefaultRetentionPolicy(365)
+ keep, drop := p.Decide(nil, time.Now())
+ if len(keep) != 0 || len(drop) != 0 {
+ t.Fatalf("expected empty results, got keep=%d drop=%d", len(keep), len(drop))
+ }
+}
+
+func TestDecide_FullDetailWindow(t *testing.T) {
+ now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC)
+ p := DefaultRetentionPolicy(365)
+
+ var execs []*happydns.Execution
+ for i := 0; i < 50; i++ {
+ execs = append(execs, mkExec(fmt.Sprintf("e%d", i), time.Duration(i)*time.Hour, now))
+ }
+
+ keep, drop := p.Decide(execs, now)
+ if len(drop) != 0 {
+ t.Fatalf("expected no drops in <7d window, got %d", len(drop))
+ }
+ if len(keep) != 50 {
+ t.Fatalf("expected 50 keeps, got %d", len(keep))
+ }
+}
+
+func TestDecide_DailyBucket(t *testing.T) {
+ now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC)
+ p := DefaultRetentionPolicy(365)
+
+ // 10 executions on the same day, ~10 days ago (inside daily window).
+ var execs []*happydns.Execution
+ for i := 0; i < 10; i++ {
+ execs = append(execs, mkExec(fmt.Sprintf("e%d", i), 10*24*time.Hour+time.Duration(i)*time.Hour, now))
+ }
+
+ keep, drop := p.Decide(execs, now)
+ if len(keep) != p.PerDayKept {
+ t.Fatalf("expected %d keeps in daily bucket, got %d", p.PerDayKept, len(keep))
+ }
+ if len(drop) != 10-p.PerDayKept {
+ t.Fatalf("expected %d drops, got %d", 10-p.PerDayKept, len(drop))
+ }
+}
+
+func TestDecide_HardCutoff(t *testing.T) {
+ now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC)
+ p := DefaultRetentionPolicy(30)
+
+ execs := []*happydns.Execution{
+ mkExec("recent", 1*24*time.Hour, now),
+ mkExec("old", 100*24*time.Hour, now),
+ }
+
+ keep, drop := p.Decide(execs, now)
+ if len(keep) != 1 || string(keep[0]) != "recent" {
+ t.Fatalf("expected 'recent' to be kept, got %v", keep)
+ }
+ if len(drop) != 1 || string(drop[0]) != "old" {
+ t.Fatalf("expected 'old' to be dropped, got %v", drop)
+ }
+}
+
+func TestDecide_GroupedByTarget(t *testing.T) {
+ now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC)
+ p := DefaultRetentionPolicy(365)
+
+ // 5 executions same day, 10 days ago, two different targets.
+ mk := func(id, dom string) *happydns.Execution {
+ return &happydns.Execution{
+ Id: happydns.Identifier(id),
+ CheckerID: "ping",
+ Target: happydns.CheckTarget{DomainId: dom},
+ StartedAt: now.Add(-10 * 24 * time.Hour),
+ }
+ }
+ var execs []*happydns.Execution
+ for i := 0; i < 5; i++ {
+ execs = append(execs, mk(fmt.Sprintf("a%d", i), "a.example"))
+ execs = append(execs, mk(fmt.Sprintf("b%d", i), "b.example"))
+ }
+
+ keep, _ := p.Decide(execs, now)
+ // PerDayKept per group => 2 * 2 groups = 4
+ if len(keep) != 2*p.PerDayKept {
+ t.Fatalf("expected %d keeps, got %d", 2*p.PerDayKept, len(keep))
+ }
+}
From 011766f1dc592433cd4ff45fa1cecba1577f54fc Mon Sep 17 00:00:00 2001
From: Pierre-Olivier Mercier
Date: Wed, 8 Apr 2026 11:51:52 +0700
Subject: [PATCH 51/54] checker: add Janitor goroutine to enforce retention
policy
The Janitor periodically walks every CheckPlan, loads its executions,
and deletes the ones that the tiered RetentionPolicy says to drop.
Per-user overrides are honoured: if a user's UserQuota.RetentionDays
is set, that horizon replaces the system default for the user's plans.
User lookups are cached per sweep to avoid repeated storage hits.
The janitor is the long-tail counterpart of the (still TODO) cheap
hard cap that will be applied at execution-creation time. It runs
immediately on Start() and then every configured interval (default 6h).
---
internal/usecase/checker/janitor.go | 185 ++++++++++++++++++++++++++++
1 file changed, 185 insertions(+)
create mode 100644 internal/usecase/checker/janitor.go
diff --git a/internal/usecase/checker/janitor.go b/internal/usecase/checker/janitor.go
new file mode 100644
index 00000000..5dff150a
--- /dev/null
+++ b/internal/usecase/checker/janitor.go
@@ -0,0 +1,185 @@
+// This file is part of the happyDomain (R) project.
+// Copyright (c) 2020-2026 happyDomain
+// Authors: Pierre-Olivier Mercier, et al.
+//
+// This program is offered under a commercial and under the AGPL license.
+// For commercial licensing, contact us at .
+//
+// For AGPL licensing:
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package checker
+
+import (
+ "context"
+ "log"
+ "sync"
+ "time"
+
+ "git.happydns.org/happyDomain/model"
+)
+
+// JanitorUserResolver resolves a user from a CheckTarget so the janitor can
+// honour per-user retention overrides stored in UserQuota.
+type JanitorUserResolver interface {
+ GetUser(id happydns.Identifier) (*happydns.User, error)
+}
+
+// Janitor periodically prunes old check executions according to the tiered
+// RetentionPolicy. It is the long-tail enforcement counterpart of the cheap
+// hard cap applied at execution-creation time.
+type Janitor struct {
+ planStore CheckPlanStorage
+ execStore ExecutionStorage
+ userResolver JanitorUserResolver
+ defaultPolicy RetentionPolicy
+ interval time.Duration
+
+ mu sync.Mutex
+ cancel context.CancelFunc
+ running bool
+}
+
+// NewJanitor builds a Janitor that runs every `interval`. The defaultPolicy
+// is applied to executions of users that did not customize their retention
+// horizon via UserQuota.
+func NewJanitor(planStore CheckPlanStorage, execStore ExecutionStorage, userResolver JanitorUserResolver, defaultPolicy RetentionPolicy, interval time.Duration) *Janitor {
+ if interval <= 0 {
+ interval = 6 * time.Hour
+ }
+ return &Janitor{
+ planStore: planStore,
+ execStore: execStore,
+ userResolver: userResolver,
+ defaultPolicy: defaultPolicy,
+ interval: interval,
+ }
+}
+
+// Start launches the janitor loop in a goroutine. It runs an immediate sweep
+// once the loop is up.
+func (j *Janitor) Start(ctx context.Context) {
+ j.mu.Lock()
+ if j.running {
+ j.mu.Unlock()
+ return
+ }
+ ctx, cancel := context.WithCancel(ctx)
+ j.cancel = cancel
+ j.running = true
+ j.mu.Unlock()
+
+ go j.loop(ctx)
+}
+
+// Stop halts the janitor.
+func (j *Janitor) Stop() {
+ j.mu.Lock()
+ defer j.mu.Unlock()
+ if j.cancel != nil {
+ j.cancel()
+ }
+ j.running = false
+}
+
+func (j *Janitor) loop(ctx context.Context) {
+ // Run immediately, then on the configured interval.
+ j.RunOnce(ctx)
+
+ ticker := time.NewTicker(j.interval)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ j.RunOnce(ctx)
+ }
+ }
+}
+
+// RunOnce performs a single sweep over all check plans, applying the per-user
+// retention policy. Returns the number of executions deleted.
+func (j *Janitor) RunOnce(ctx context.Context) int {
+ iter, err := j.planStore.ListAllCheckPlans()
+ if err != nil {
+ log.Printf("Janitor: failed to list check plans: %v", err)
+ return 0
+ }
+
+ now := time.Now()
+ deleted := 0
+
+ // Cache user policies to avoid resolving the same user repeatedly.
+ policyByUser := map[string]RetentionPolicy{}
+
+ for iter.Next() {
+ select {
+ case <-ctx.Done():
+ return deleted
+ default:
+ }
+
+ plan := iter.Item()
+ if plan == nil {
+ continue
+ }
+
+ execs, err := j.execStore.ListExecutionsByPlan(plan.Id)
+ if err != nil {
+ log.Printf("Janitor: failed to list executions for plan %s: %v", plan.Id.String(), err)
+ continue
+ }
+ if len(execs) == 0 {
+ continue
+ }
+
+ policy := j.policyForTarget(plan.Target, policyByUser)
+ _, drop := policy.Decide(execs, now)
+
+ for _, id := range drop {
+ if err := j.execStore.DeleteExecution(id); err != nil {
+ log.Printf("Janitor: failed to delete execution %s: %v", id.String(), err)
+ continue
+ }
+ deleted++
+ }
+ }
+
+ if deleted > 0 {
+ log.Printf("Janitor: pruned %d executions", deleted)
+ }
+ return deleted
+}
+
+func (j *Janitor) policyForTarget(target happydns.CheckTarget, cache map[string]RetentionPolicy) RetentionPolicy {
+ uid := target.UserId
+ if uid == "" || j.userResolver == nil {
+ return j.defaultPolicy
+ }
+ if p, ok := cache[uid]; ok {
+ return p
+ }
+ policy := j.defaultPolicy
+ id, err := happydns.NewIdentifierFromString(uid)
+ if err == nil {
+ if user, err := j.userResolver.GetUser(id); err == nil && user != nil {
+ if user.Quota.RetentionDays > 0 {
+ policy = DefaultRetentionPolicy(user.Quota.RetentionDays)
+ }
+ }
+ }
+ cache[uid] = policy
+ return policy
+}
From 94cb9a41295c95c7ca26a5802f9a8ff376a35184 Mon Sep 17 00:00:00 2001
From: Pierre-Olivier Mercier
Date: Wed, 8 Apr 2026 11:52:46 +0700
Subject: [PATCH 52/54] checker: pause scheduling for paused or inactive users
Add a job-level gate to the scheduler. When set, the gate is consulted
on every popped job; if it returns false, the job is skipped and
re-enqueued for its next interval without invoking the engine.
A new UserGater builds such a gate from a user resolver and an
inactivity threshold:
- users with UserQuota.SchedulingPaused are always blocked (admin
kill switch);
- users whose LastSeen is older than their effective inactivity
horizon (UserQuota.InactivityPauseDays, falling back to a system
default) are blocked until they log in again;
- lookups are cached for 5 minutes so the scheduler hot path stays
cheap, with an Invalidate hook for use on user updates.
This addresses the "free trial then forgotten" failure mode described
in the design notes.
---
internal/usecase/checker/scheduler.go | 30 +++++++
internal/usecase/checker/user_gate.go | 119 ++++++++++++++++++++++++++
2 files changed, 149 insertions(+)
create mode 100644 internal/usecase/checker/user_gate.go
diff --git a/internal/usecase/checker/scheduler.go b/internal/usecase/checker/scheduler.go
index 31af4afd..3e74fb5e 100644
--- a/internal/usecase/checker/scheduler.go
+++ b/internal/usecase/checker/scheduler.go
@@ -108,6 +108,19 @@ type Scheduler struct {
running bool
ctx context.Context
maxConcurrency int
+
+ // gate, if set, is consulted before launching each job. Returning false
+ // causes the scheduler to skip (and reschedule) the job, e.g. when the
+ // owning user is paused or has been inactive for too long.
+ gate func(target happydns.CheckTarget) bool
+}
+
+// SetGate installs a job gate evaluated before each execution. It is safe to
+// call after Start(); the gate is consulted on every job pop.
+func (s *Scheduler) SetGate(gate func(target happydns.CheckTarget) bool) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.gate = gate
}
// NewScheduler creates a new Scheduler.
@@ -247,8 +260,25 @@ func (s *Scheduler) run(ctx context.Context) {
continue
}
job := heap.Pop(&s.queue).(*SchedulerJob)
+ gate := s.gate
s.mu.Unlock()
+ // Honour the user-level gate before doing any work.
+ if gate != nil && !gate(job.Target) {
+ log.Printf("Scheduler: skipping checker %s on %s (gated by user policy)", job.CheckerID, job.Target.String())
+ now := time.Now()
+ for job.NextRun.Before(now) {
+ job.NextRun = job.NextRun.Add(job.Interval)
+ }
+ job.NextRun = job.NextRun.Add(computeJitter(job.CheckerID, job.Target.String(), job.NextRun, job.Interval))
+ key := job.CheckerID + "|" + job.Target.String()
+ s.mu.Lock()
+ heap.Push(&s.queue, job)
+ s.jobKeys[key] = true
+ s.mu.Unlock()
+ continue
+ }
+
// Find plan if applicable.
var plan *happydns.CheckPlan
if job.PlanID != nil {
diff --git a/internal/usecase/checker/user_gate.go b/internal/usecase/checker/user_gate.go
new file mode 100644
index 00000000..ffcf9458
--- /dev/null
+++ b/internal/usecase/checker/user_gate.go
@@ -0,0 +1,119 @@
+// This file is part of the happyDomain (R) project.
+// Copyright (c) 2020-2026 happyDomain
+// Authors: Pierre-Olivier Mercier, et al.
+//
+// This program is offered under a commercial and under the AGPL license.
+// For commercial licensing, contact us at .
+//
+// For AGPL licensing:
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package checker
+
+import (
+ "sync"
+ "time"
+
+ "git.happydns.org/happyDomain/model"
+)
+
+// UserGater builds a Scheduler gate function that filters out check jobs
+// belonging to users that are paused or have been inactive for too long.
+//
+// Lookups are cached for a short TTL so the scheduler hot path does not hit
+// storage on every job pop.
+type UserGater struct {
+ resolver JanitorUserResolver
+ defaultInactivityDays int
+ cacheTTL time.Duration
+
+ mu sync.Mutex
+ cache map[string]gateCacheEntry
+}
+
+type gateCacheEntry struct {
+ allow bool
+ expires time.Time
+}
+
+// NewUserGater creates a UserGater. defaultInactivityDays is used for users
+// whose UserQuota.InactivityPauseDays is zero. A negative effective value
+// disables inactivity-based pausing for that user.
+func NewUserGater(resolver JanitorUserResolver, defaultInactivityDays int) *UserGater {
+ return &UserGater{
+ resolver: resolver,
+ defaultInactivityDays: defaultInactivityDays,
+ cacheTTL: 5 * time.Minute,
+ cache: map[string]gateCacheEntry{},
+ }
+}
+
+// Allow returns true if the scheduler should run jobs for the given target.
+func (g *UserGater) Allow(target happydns.CheckTarget) bool {
+ uid := target.UserId
+ if uid == "" || g.resolver == nil {
+ return true
+ }
+
+ g.mu.Lock()
+ if e, ok := g.cache[uid]; ok && time.Now().Before(e.expires) {
+ g.mu.Unlock()
+ return e.allow
+ }
+ g.mu.Unlock()
+
+ allow := g.compute(uid)
+
+ g.mu.Lock()
+ g.cache[uid] = gateCacheEntry{allow: allow, expires: time.Now().Add(g.cacheTTL)}
+ g.mu.Unlock()
+
+ return allow
+}
+
+// Invalidate drops any cached decision for the given user. Call this when a
+// user's quota or LastSeen changes (e.g. on login or admin update).
+func (g *UserGater) Invalidate(userID string) {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ delete(g.cache, userID)
+}
+
+func (g *UserGater) compute(uid string) bool {
+ id, err := happydns.NewIdentifierFromString(uid)
+ if err != nil {
+ return true
+ }
+ user, err := g.resolver.GetUser(id)
+ if err != nil || user == nil {
+ // Be conservative: allow rather than silently dropping work.
+ return true
+ }
+ if user.Quota.SchedulingPaused {
+ return false
+ }
+
+ days := user.Quota.InactivityPauseDays
+ if days == 0 {
+ days = g.defaultInactivityDays
+ }
+ if days <= 0 {
+ return true
+ }
+ if user.LastSeen.IsZero() {
+ return true
+ }
+ cutoff := time.Now().AddDate(0, 0, -days)
+ return user.LastSeen.After(cutoff)
+}
From 5b29ed560576f7faf7cb08225f414f2e9b79c9a2 Mon Sep 17 00:00:00 2001
From: Pierre-Olivier Mercier
Date: Wed, 8 Apr 2026 11:54:43 +0700
Subject: [PATCH 53/54] app: wire checker retention janitor and user gate
Construct the retention janitor and the user gate alongside the
checker scheduler. Three new options drive their behaviour:
--checker-retention-days (default 365)
--checker-janitor-interval (default 6h)
--checker-inactivity-pause-days (default 90)
The janitor starts immediately on App.Start and is shut down on
App.Stop. The user gate is installed on the scheduler with the same
storage-backed user resolver, so paused users and users that haven't
logged in for the configured horizon stop being checked until they
come back.
---
internal/app/app.go | 24 ++++++++++++++++++++++++
internal/config/cli.go | 4 ++++
model/config.go | 15 +++++++++++++++
3 files changed, 43 insertions(+)
diff --git a/internal/app/app.go b/internal/app/app.go
index 355d8309..cc695da3 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -76,6 +76,7 @@ type Usecases struct {
checkerPlanUC *checkerUC.CheckPlanUsecase
checkerStatusUC *checkerUC.CheckStatusUsecase
checkerScheduler *checkerUC.Scheduler
+ checkerJanitor *checkerUC.Janitor
}
type App struct {
@@ -274,6 +275,21 @@ func (app *App) initUsecases() {
app.usecases.checkerScheduler = checkerUC.NewScheduler(app.usecases.checkerEngine, app.cfg.CheckerMaxConcurrency, app.store, app.store, app.store, app.store)
app.usecases.checkerStatusUC.SetPlannedJobProvider(app.usecases.checkerScheduler)
+ // Install the user-level gate so paused or long-inactive users do not
+ // get checked. The same user resolver is reused by the janitor for
+ // per-user retention overrides.
+ gater := checkerUC.NewUserGater(app.store, app.cfg.CheckerInactivityPauseDays)
+ app.usecases.checkerScheduler.SetGate(gater.Allow)
+
+ // Retention janitor.
+ app.usecases.checkerJanitor = checkerUC.NewJanitor(
+ app.store,
+ app.store,
+ app.store,
+ checkerUC.DefaultRetentionPolicy(app.cfg.CheckerRetentionDays),
+ app.cfg.CheckerJanitorInterval,
+ )
+
// Wire scheduler notifications for incremental queue updates.
domainService.SetSchedulerNotifier(app.usecases.checkerScheduler)
app.usecases.orchestrator.SetSchedulerNotifier(app.usecases.checkerScheduler)
@@ -348,6 +364,10 @@ func (app *App) Start() {
app.usecases.checkerScheduler.Start(context.Background())
}
+ if app.usecases.checkerJanitor != nil {
+ app.usecases.checkerJanitor.Start(context.Background())
+ }
+
log.Printf("Public interface listening on %s\n", app.cfg.Bind)
if err := app.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %s\n", err)
@@ -365,6 +385,10 @@ func (app *App) Stop() {
app.usecases.checkerScheduler.Stop()
}
+ if app.usecases.checkerJanitor != nil {
+ app.usecases.checkerJanitor.Stop()
+ }
+
// Close storage
if app.store != nil {
app.store.Close()
diff --git a/internal/config/cli.go b/internal/config/cli.go
index fecf0c7d..36106b4b 100644
--- a/internal/config/cli.go
+++ b/internal/config/cli.go
@@ -25,6 +25,7 @@ import (
"flag"
"fmt"
"runtime"
+ "time"
"git.happydns.org/happyDomain/internal/storage"
"git.happydns.org/happyDomain/model"
@@ -47,6 +48,9 @@ func declareFlags(o *happydns.Options) {
flag.Var(&URL{&o.ExternalAuth}, "external-auth", "Base URL to use for login and registration (use embedded forms if left empty)")
flag.BoolVar(&o.OptOutInsights, "opt-out-insights", false, "Disable the anonymous usage statistics report. If you care about this project and don't participate in discussions, don't opt-out.")
flag.IntVar(&o.CheckerMaxConcurrency, "checker-max-concurrency", runtime.NumCPU(), "Maximum number of checker jobs that can run simultaneously")
+ flag.IntVar(&o.CheckerRetentionDays, "checker-retention-days", 365, "System-wide default retention horizon for check execution history (overridable per user)")
+ flag.DurationVar(&o.CheckerJanitorInterval, "checker-janitor-interval", 6*time.Hour, "How often the checker retention janitor runs")
+ flag.IntVar(&o.CheckerInactivityPauseDays, "checker-inactivity-pause-days", 90, "Pause checks for users that haven't logged in for this many days (0 disables, overridable per user)")
flag.Var(&URL{&o.ListmonkURL}, "newsletter-server-url", "Base URL of the listmonk newsletter server")
flag.IntVar(&o.ListmonkID, "newsletter-id", 1, "Listmonk identifier of the list receiving the new user")
diff --git a/model/config.go b/model/config.go
index 45048aa6..b81e1bde 100644
--- a/model/config.go
+++ b/model/config.go
@@ -26,6 +26,7 @@ import (
"net/mail"
"net/url"
"path"
+ "time"
)
// Options stores the configuration of the software.
@@ -97,6 +98,20 @@ type Options struct {
// run simultaneously. Defaults to runtime.NumCPU().
CheckerMaxConcurrency int
+ // CheckerRetentionDays is the system-wide default for how many days of
+ // check execution history are kept. Per-user UserQuota.RetentionDays
+ // overrides this value.
+ CheckerRetentionDays int
+
+ // CheckerJanitorInterval is how often the retention janitor runs.
+ CheckerJanitorInterval time.Duration
+
+ // CheckerInactivityPauseDays is the system-wide default number of days
+ // without login after which the scheduler stops running checks for a
+ // user. 0 disables inactivity pausing globally; per-user UserQuota
+ // overrides this value.
+ CheckerInactivityPauseDays int
+
// CaptchaProvider selects the captcha provider ("hcaptcha", "recaptchav2", "turnstile", or "").
CaptchaProvider string
From b1d7df8d3c190dc26a4d481c379035624ae8dc25 Mon Sep 17 00:00:00 2001
From: Pierre-Olivier Mercier
Date: Wed, 8 Apr 2026 12:02:04 +0700
Subject: [PATCH 54/54] checker: keep 1 report per hour after the first day
Insert an hourly tier between the full-detail window and the daily
bucket so users still get sub-day resolution for the first week:
0..1 day -> all
1..7 days -> 1 per hour
7..30 -> 2 per day
...
---
internal/usecase/checker/retention.go | 23 +++++++++++++---
internal/usecase/checker/retention_test.go | 31 ++++++++++++++++++----
2 files changed, 46 insertions(+), 8 deletions(-)
diff --git a/internal/usecase/checker/retention.go b/internal/usecase/checker/retention.go
index 1c67840b..51483fde 100644
--- a/internal/usecase/checker/retention.go
+++ b/internal/usecase/checker/retention.go
@@ -37,7 +37,8 @@ import (
//
// age window | kept
// ------------------------- | ------------------------------------------
-// 0 .. 7 days | every execution
+// 0 .. 1 day | every execution
+// 1 .. 7 days | up to 1 execution per hour per (checker,target)
// 7 .. 30 days | up to 2 executions per day per (checker,target)
// 30 .. D/2 days | up to 1 execution per week per (checker,target)
// D/2 .. D days | up to 1 execution per month per (checker,target)
@@ -52,7 +53,11 @@ type RetentionPolicy struct {
// FullDetailDays: every execution kept under this age.
FullDetailDays int
- // DailyBucketDays: between FullDetailDays and DailyBucketDays, keep
+ // HourlyBucketDays: between FullDetailDays and HourlyBucketDays, keep
+ // PerHourKept executions per UTC hour per (checker,target).
+ HourlyBucketDays int
+ PerHourKept int
+ // DailyBucketDays: between HourlyBucketDays and DailyBucketDays, keep
// PerDayKept executions per UTC day per (checker,target).
DailyBucketDays int
PerDayKept int
@@ -73,7 +78,9 @@ func DefaultRetentionPolicy(retentionDays int) RetentionPolicy {
}
return RetentionPolicy{
RetentionDays: retentionDays,
- FullDetailDays: 7,
+ FullDetailDays: 1,
+ HourlyBucketDays: 7,
+ PerHourKept: 1,
DailyBucketDays: 30,
PerDayKept: 2,
WeeklyBucketDays: max(retentionDays/2, 31),
@@ -104,6 +111,7 @@ func (p RetentionPolicy) Decide(executions []*happydns.Execution, now time.Time)
hardCutoff := now.AddDate(0, 0, -p.RetentionDays)
fullCutoff := now.AddDate(0, 0, -p.FullDetailDays)
+ hourlyCutoff := now.AddDate(0, 0, -p.HourlyBucketDays)
dailyCutoff := now.AddDate(0, 0, -p.DailyBucketDays)
weeklyCutoff := now.AddDate(0, 0, -p.WeeklyBucketDays)
@@ -113,6 +121,7 @@ func (p RetentionPolicy) Decide(executions []*happydns.Execution, now time.Time)
return group[i].StartedAt.After(group[j].StartedAt)
})
+ hourBuckets := map[string]int{}
dayBuckets := map[string]int{}
weekBuckets := map[string]int{}
monthBuckets := map[string]int{}
@@ -125,6 +134,14 @@ func (p RetentionPolicy) Decide(executions []*happydns.Execution, now time.Time)
case !t.Before(fullCutoff):
// 0 .. FullDetailDays — keep everything.
keep = append(keep, e.Id)
+ case !t.Before(hourlyCutoff):
+ k := t.UTC().Format("2006-01-02T15")
+ if hourBuckets[k] < p.PerHourKept {
+ hourBuckets[k]++
+ keep = append(keep, e.Id)
+ } else {
+ drop = append(drop, e.Id)
+ }
case !t.Before(dailyCutoff):
k := t.UTC().Format("2006-01-02")
if dayBuckets[k] < p.PerDayKept {
diff --git a/internal/usecase/checker/retention_test.go b/internal/usecase/checker/retention_test.go
index 7d1383b1..3badb107 100644
--- a/internal/usecase/checker/retention_test.go
+++ b/internal/usecase/checker/retention_test.go
@@ -50,17 +50,38 @@ func TestDecide_FullDetailWindow(t *testing.T) {
now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC)
p := DefaultRetentionPolicy(365)
+ // 20 executions in the first 20 minutes — all inside 0..1 day window.
var execs []*happydns.Execution
- for i := 0; i < 50; i++ {
- execs = append(execs, mkExec(fmt.Sprintf("e%d", i), time.Duration(i)*time.Hour, now))
+ for i := 0; i < 20; i++ {
+ execs = append(execs, mkExec(fmt.Sprintf("e%d", i), time.Duration(i)*time.Minute, now))
}
keep, drop := p.Decide(execs, now)
if len(drop) != 0 {
- t.Fatalf("expected no drops in <7d window, got %d", len(drop))
+ t.Fatalf("expected no drops in <1d window, got %d", len(drop))
}
- if len(keep) != 50 {
- t.Fatalf("expected 50 keeps, got %d", len(keep))
+ if len(keep) != 20 {
+ t.Fatalf("expected 20 keeps, got %d", len(keep))
+ }
+}
+
+func TestDecide_HourlyBucket(t *testing.T) {
+ now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC)
+ p := DefaultRetentionPolicy(365)
+
+ // 6 executions in the same hour ~3 days ago (inside hourly window).
+ var execs []*happydns.Execution
+ base := 3*24*time.Hour + 30*time.Minute
+ for i := 0; i < 6; i++ {
+ execs = append(execs, mkExec(fmt.Sprintf("e%d", i), base+time.Duration(i)*time.Minute, now))
+ }
+
+ keep, drop := p.Decide(execs, now)
+ if len(keep) != p.PerHourKept {
+ t.Fatalf("expected %d keeps in hourly bucket, got %d", p.PerHourKept, len(keep))
+ }
+ if len(drop) != 6-p.PerHourKept {
+ t.Fatalf("expected %d drops, got %d", 6-p.PerHourKept, len(drop))
}
}