Compare commits

...

50 commits

Author SHA1 Message Date
d0eeb4ea32 Add a checker for ICMP Ping
Some checks failed
continuous-integration/drone/push Build is failing
2026-03-16 23:09:09 +07:00
3163218cf6 Introduce checker returning metrics 2026-03-16 23:09:09 +07:00
d41671970f Add a checker for Matrix Federation 2026-03-16 23:09:09 +07:00
5f27ce1bb3 Handle checks on services 2026-03-16 23:09:09 +07:00
2967f80a47 Enrich domain listing with worst check status
Add last_check_status to the GET /domains response by aggregating the
most recent result of each checker per domain and reporting the worst
(most critical) status. The frontend domain list now renders a badge
with the check status that links to the domain's checks page.
2026-03-16 23:09:09 +07:00
f96e75dd05 web: Refactor check result page: move metadata to sidebar component
Extract check result metadata (status, options, actions) into a new
CheckResultSidebar component rendered in the domain layout sidebar,
allowing the main content area to be used for full-screen report display.
Shared state is managed via new Svelte stores (currentCheckResult,
currentCheckInfo, showHTMLReport).
2026-03-16 23:09:09 +07:00
0303f38779 Add CheckerHTMLReporter interface and Zonemaster HTML report
Introduces an optional CheckerHTMLReporter interface that checkers can
implement to expose a rich HTML document built from their stored Report
field.  The Zonemaster checker implements it, rendering results grouped
by module in collapsible accordions with color-coded severity badges.
2026-03-16 23:09:09 +07:00
421de45d42 Add a checker for Zonemaster 2026-03-16 23:09:09 +07:00
692c9f9e37 Implement auto-fill variables for checker option fields
Add an AutoFill attribute to the Field struct that marks option fields
as automatically resolved by the software based on test context, rather
than requiring user input. Auto-fill always overrides any user-provided
value at execution time.
2026-03-16 23:09:09 +07:00
4f800cc4ca web: Add frontend for domain tests browsing and execution
Add test API client, data models, Svelte store, and pages to list
available tests per domain, view results, and trigger test runs via a
dedicated modal. Also refactor plugins page to use a shared store.
2026-03-16 23:09:09 +07:00
d15420f37d Add admin API and frontend for scheduler management 2026-03-16 23:09:09 +07:00
6ad0e1481a Implement checks scheduler 2026-03-16 23:09:09 +07:00
d9b9eca7cb Implement backend model for test results and schedule 2026-03-16 23:09:09 +07:00
c6ef40a088 Add checker interface: api routes and frontend to manage user checker 2026-03-16 23:09:09 +07:00
5d33a0bd46 Add checker routes to API + refactor check controller 2026-03-16 23:08:55 +07:00
857745b19b web-admin: Implement checkers interface with option editor 2026-03-16 23:08:55 +07:00
bb58e5641c Implement checker options retrieval 2026-03-16 23:08:55 +07:00
7bb29708c6 Add usescases to handle checkers 2026-03-16 23:08:55 +07:00
3bfd68c51d Write plugin technical documentation 2026-03-16 23:08:55 +07:00
4ed76a8a11 Load checks plugins 2026-03-16 23:08:55 +07:00
c384c10a88 New custom flag parser: ArrayArgs 2026-03-16 23:08:55 +07:00
f16ae2991e fix: refresh ButtonZonePublish after zone apply
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-16 21:00:35 +07:00
72fa2b3904 fix: wire ActionOnEditableZone into all zone mutation facades
All checks were successful
continuous-integration/drone/push Build is passing
Add/update/delete service calls in the Service facade were bypassing
ActionOnEditableZone, so mutations could silently target a committed or
published zone instead of deriving a new editable snapshot first.

Wraps AddServiceToZone, RemoveServiceFromZone, and UpdateZoneService
with ActionOnEditableZone so the decorator is applied consistently.

Fixes regression introduced by b2b6467575.
2026-03-16 20:07:47 +07:00
a7b225b9df Rework zone diff/apply flow: separate diff from provider API, support partial apply
Decouple diff computation from executable provider closures by fetching
provider records and computing diffs locally via DNSControlDiffByRecord.
On apply, build a target record set from user-selected corrections using
BuildTargetRecords, then ask the provider for executable corrections
against that target. A published snapshot is inserted at ZoneHistory[1]
while the WIP zone at position 0 remains unchanged.
2026-03-16 19:46:09 +07:00
8a2a28e4be providers: Mark secret fields with secret tag; add eye toggle for secret inputs
All checks were successful
continuous-integration/drone/push Build is passing
Also fix a typo in oracle.go label ("Private hey" → "Private key").
2026-03-16 19:44:14 +07:00
e341ea6beb chore(deps): lock file maintenance 2026-03-16 19:44:14 +07:00
69c9ba1d8d Expand authuser test coverage: hash functions, validation, recovery, and bcrypt limit 2026-03-16 19:44:14 +07:00
50ff2a1c7a Replace nil mailer checks with LogMailer fallback
Add a LogMailer that prints emails to stdout when no mail transport is
configured, eliminating the reflect-based nil interface checks that were
scattered across the authuser package. The App now always injects a
non-nil Mailer, so the usecase layer no longer needs to guard against it.
2026-03-16 19:44:14 +07:00
fece9cc4a5 Improve password validation performance and email format checking 2026-03-16 19:44:14 +07:00
9203e71494 web: Rename /join route to /register for clarity 2026-03-16 19:44:14 +07:00
36a7d8e9d3 Fix email validation HMAC weakness and prevent user enumeration on registration 2026-03-16 19:44:14 +07:00
ae675d6451 Refactor provider usecase: fix ownership bug, use decorator pattern, enforce service layer
- Fix DeleteProvider skipping ownership check by adding getUserProvider call
- Replace RestrictedService struct embedding with decorator pattern to prevent
  silent method promotion bypassing restriction checks
- Make providerSettingsUsecase delegate to ProviderUsecase instead of accessing
  storage directly, ensuring validation and ownership are enforced
- Accept ProviderValidator as constructor parameter, removing SetValidator mutator
- Add instantiate() helper for consistent provider instantiation error handling
- Wrap ListUserProviders storage errors in InternalError for consistency
- Add Test_DeleteProvider_WrongUser test and reduce test boilerplate
2026-03-16 19:44:14 +07:00
c850cfb0db Refactor orchestrator: add context.Context, fix error handling, use interfaces
- Handle AppendDomainLog errors with log.Printf instead of silently discarding
- Add NoopDomainLogAppender for null object pattern
2026-03-16 19:44:14 +07:00
07b5553369 Add public DNS record generator pages at /generator
Expose service editors publicly (no auth required) at /generator for
SEO discoverability. Each page shows an interactive editor alongside
a live DNS zone record preview powered by a new POST
/service_specs/:ssid/records backend endpoint.
2026-03-16 19:44:13 +07:00
572b4ea167 web: New helper domainJoin, fix OpenPGPKEY and SMIMECERT records when dn is empty 2026-03-15 21:35:45 +07:00
0fb2f048f7 Add missing documentation to some usecases 2026-03-15 17:40:15 +07:00
0dd7135781 Refactor ZoneCorrectionApplierUsecase: fix bugs and improve structure
Extract List method into a dedicated ZoneCorrectionListerUsecase to
separate concerns, and fix several bugs in Apply:

- Fix early-break condition: track appliedCount instead of using the
  correction index, which incorrectly compared against the position in
  all corrections rather than applied ones.
- Stop mutating form.WantedCorrections in-place; use a matched slice
  to track applied corrections without side effects.
- Fix misleading UserMessage strings that all said "unable to create
  the zone" regardless of which step failed.
- Use a single clock call for CommitDate, Published, and LastModified
  instead of two separate time.Now() calls producing different timestamps.
- Inject a clock function for testability.
- Improve error messages to include applied/total correction counts.
2026-03-15 17:40:15 +07:00
89362f473f ci: fix yarn v1 vite hoisting issue for vitest on amd64
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-15 17:40:15 +07:00
943d9b2a0c web: Add drag-and-drop domain group reassignment in ZoneList
When display_by_groups is enabled, domains are now draggable and group
containers act as drop targets. Dropping a domain onto a different group
updates its group via the API and refreshes the domain list.
2026-03-15 17:40:15 +07:00
d4090f983a Add a security policy 2026-03-15 17:40:15 +07:00
94806782e1 ci: Add SBOM generation in SPDX format 2026-03-15 17:40:15 +07:00
1be73506cb Reformat long function signatures 2026-03-14 12:02:00 +07:00
2572e8c319 Preserve service metadata across zone re-analyses
After AnalyzeZone rebuilds services from raw DNS records, metadata that
cannot be derived from DNS (Id, UserComment, OwnerId, Aliases, TTL, and
service-specific fields like OpenPGP/SMimeCert Username) was lost.

Add a post-processing function ReassociateMetadata that matches new
services to old ones by type and subdomain (using RDATA hashing for
disambiguation) and transfers metadata. Services opt in to body-level
transfer via the new MetadataEnricher interface.
2026-03-14 11:06:49 +07:00
f4bcb1c9cf refactor: decompose Analyzer into recordPool and serviceAccumulator
Restructure the service analyzer architecture to improve maintainability:

- Extract recordPool (zone records + mark-delete claiming) and
  serviceAccumulator (service registry + domain normalization) as
  embedded structs in Analyzer
- Replace swap-delete with mark-delete to eliminate mutation-during-iteration
- Centralize domain normalization using helpers.DomainRelative
- Make Comment/NbResources lazy via Service.MarshalJSON instead of
  eager assignment at three separate call sites
- Extract SPF merging from usecase layer into services.CollectAndMergeSPF
- Add GetDefaultTTL accessor and comprehensive Analyzer doc comments
- Add round-trip test infrastructure covering MX, CNAME, CAA, TXT, SPF,
  DMARC, GSuite, Origin, Server and more
2026-03-14 11:06:49 +07:00
6de814a247 docs: add comments to all functions and types in analyzer.go
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 10:36:53 +07:00
2a00d69ebb refactor: use random identifiers instead of SHA1 hashes for service IDs
Replace SHA1-based service ID generation with happydns.NewRandomIdentifier()
for consistency with the rest of the codebase.
2026-03-14 10:36:53 +07:00
3d196088c2 fix: prevent duplicate results in SearchRR when multiple filters match
When a record matched more than one AnalyzerRecordFilter, it was
appended to the result slice multiple times. Break after the first
matching filter to include each record at most once.
2026-03-14 10:36:53 +07:00
c7f309b867 fix: break after finding record in UseRR swap-delete loop
The swap-and-shrink deletion inside a range loop skipped the element
swapped into position k. Since there should only be one matching
record (pointer equality), breaking immediately is both correct and
clearer.
2026-03-14 10:36:53 +07:00
31950811c0 Merge SPF records from multiple services into single TXT record
RFC 7208 requires exactly one SPF record per domain. Previously, the
standalone SPF service and provider services like GSuite each emitted
their own SPF TXT record, producing invalid DNS when both existed.

Introduce SPFContributor interface so services can declare SPF
directives independently. At zone generation time, all contributions
for the same domain are merged into a single SPF record with the
strictest "all" policy winning. During zone import, GSuite claims its
directive via ClaimSPFDirective so the SPF analyzer excludes it from
the standalone SPF service.
2026-03-14 10:36:53 +07:00
fff3c29876 docs: add AI disclaimer section to README
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-12 17:47:12 +07:00
262 changed files with 21047 additions and 1351 deletions

View file

@ -22,55 +22,19 @@ steps:
- yarn config set network-timeout 100000
- yarn --cwd web install
- tar --transform="s@.@./happydomain-${DRONE_COMMIT}@" --exclude-vcs --exclude=./web/node_modules/.cache -czf /dev/shm/happydomain-src.tar.gz .
- mv /dev/shm/happydomain-src.tar.gz .
- mkdir deploy
- mv /dev/shm/happydomain-src.tar.gz deploy
- yarn --cwd web --offline generate:api && sed -i "s/hey-api\.ts';/hey-api';/" web/src/lib/api-base/client.gen.ts
- yarn --cwd web --offline build
- yarn --cwd web-admin --offline generate:api && sed -i "s/hey-api\.ts';/hey-api';/" web/src/lib/api-admin/client.gen.ts
- yarn --cwd web-admin --offline build
- name: deploy sources
image: plugins/s3
settings:
endpoint: https://blob.nemunai.re
path_style: true
region: garage
bucket: happydomain-dl
access_key:
from_secret: s3_access_key
secret_key:
from_secret: s3_secret_key
source: happydomain-src.tar.gz
target: /${DRONE_BRANCH//\//-}/
when:
event:
- push
branch:
exclude:
- renovate/*
- name: deploy sources for release
image: plugins/s3
settings:
endpoint: https://blob.nemunai.re
path_style: true
region: garage
bucket: happydomain-dl
access_key:
from_secret: s3_access_key
secret_key:
from_secret: s3_secret_key
source: happydomain-src.tar.gz
target: /${DRONE_TAG}/
when:
event:
- tag
- name: backend-commit
image: golang:1-alpine
commands:
- apk add --no-cache git
- go build -tags netgo,swagger,web -ldflags '-w -X "main.Version=${DRONE_BRANCH}-${DRONE_COMMIT}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDomain/
- ln happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} happydomain
- go build -tags netgo,swagger,web -ldflags '-w -X "main.Version=${DRONE_BRANCH}-${DRONE_COMMIT}" -X main.build=${DRONE_BUILD_NUMBER}' -o deploy/happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDomain/
- ln deploy/happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} happydomain
environment:
CGO_ENABLED: 0
when:
@ -82,14 +46,48 @@ steps:
image: golang:1-alpine
commands:
- apk add --no-cache git
- go build -tags netgo,swagger,web -ldflags '-w -X main.Version=${DRONE_TAG##v} -X main.build=${DRONE_BUILD_NUMBER}' -o happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDomain/
- ln happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} happydomain
- go build -tags netgo,swagger,web -ldflags '-w -X main.Version=${DRONE_TAG##v} -X main.build=${DRONE_BUILD_NUMBER}' -o deploy/happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDomain/
- ln deploy/happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} happydomain
environment:
CGO_ENABLED: 0
when:
event:
- tag
- name: generate SBOM
image: nemunaire/drone-syft
settings:
select_catalogers: go,npm
output: spdx-json=deploy/happydomain-sbom.spdx.json
source_name: happyDomain
- name: build-commit macOS
image: golang:1-alpine
commands:
- apk add --no-cache git
- go build -tags netgo,swagger,web -ldflags '-w -X "main.Version=${DRONE_BRANCH}-${DRONE_COMMIT}" -X main.build=${DRONE_BUILD_NUMBER}' -o deploy/happydomain-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDomain/
environment:
CGO_ENABLED: 0
GOOS: darwin
GOARCH: amd64
when:
event:
exclude:
- tag
- name: build-tag macOS
image: golang:1-alpine
commands:
- apk add --no-cache git
- go build -tags netgo,swagger,web -ldflags '-w -X "main.Version=${DRONE_TAG##v}" -X main.build=${DRONE_BUILD_NUMBER}' -o deploy/happydomain-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDomain/
environment:
CGO_ENABLED: 0
GOOS: darwin
GOARCH: amd64
when:
event:
- tag
- name: deploy
image: plugins/s3
settings:
@ -101,8 +99,9 @@ steps:
from_secret: s3_access_key
secret_key:
from_secret: s3_secret_key
source: happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
source: deploy/*
target: /${DRONE_BRANCH//\//-}/
strip_prefix: deploy/
when:
event:
- push
@ -121,72 +120,9 @@ steps:
from_secret: s3_access_key
secret_key:
from_secret: s3_secret_key
source: happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
target: /${DRONE_TAG}/
when:
event:
- tag
- name: build-commit macOS
image: golang:1-alpine
commands:
- apk add --no-cache git
- go build -tags netgo,swagger,web -ldflags '-w -X "main.Version=${DRONE_BRANCH}-${DRONE_COMMIT}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydomain-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDomain/
environment:
CGO_ENABLED: 0
GOOS: darwin
GOARCH: amd64
when:
event:
exclude:
- tag
- name: build-tag macOS
image: golang:1-alpine
commands:
- apk add --no-cache git
- go build -tags netgo,swagger,web -ldflags '-w -X "main.Version=${DRONE_TAG##v}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydomain-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDomain/
environment:
CGO_ENABLED: 0
GOOS: darwin
GOARCH: amd64
when:
event:
- tag
- name: deploy macOS
image: plugins/s3
settings:
endpoint: https://blob.nemunai.re
path_style: true
region: garage
bucket: happydomain-dl
access_key:
from_secret: s3_access_key
secret_key:
from_secret: s3_secret_key
source: happydomain-darwin-${DRONE_STAGE_ARCH}
target: /${DRONE_BRANCH//\//-}/
when:
event:
- push
branch:
exclude:
- renovate/*
- name: deploy macOS release
image: plugins/s3
settings:
endpoint: https://blob.nemunai.re
path_style: true
region: garage
bucket: happydomain-dl
access_key:
from_secret: s3_access_key
secret_key:
from_secret: s3_secret_key
source: happydomain-darwin-${DRONE_STAGE_ARCH}
source: deploy/*
target: /${DRONE_TAG}/
strip_prefix: deploy/
when:
event:
- tag
@ -210,11 +146,11 @@ steps:
from_secret: git_nemunaire_token
base_url: https://git.nemunai.re
draft: true
prerelease: true
files:
- happydomain-src.tar.gz
- happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
- happydomain-darwin-${DRONE_STAGE_ARCH}
- happydomain-sbom.spdx.json
when:
event:
- tag
@ -226,11 +162,11 @@ steps:
from_secret: codeberg_token
base_url: https://codeberg.org
draft: true
prerelease: true
files:
- happydomain-src.tar.gz
- happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
- happydomain-darwin-${DRONE_STAGE_ARCH}
- happydomain-sbom.spdx.json
when:
event:
- tag
@ -241,12 +177,12 @@ steps:
api_key:
from_secret: github_release_token
draft: true
prerelease: true
github_url: https://github.com
files:
- happydomain-src.tar.gz
- happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
- happydomain-darwin-${DRONE_STAGE_ARCH}
- happydomain-sbom.spdx.json
when:
event:
- tag
@ -294,8 +230,8 @@ steps:
image: golang:1-alpine
commands:
- apk add --no-cache git
- go build -tags netgo,swagger,web -ldflags '-w -X "main.Version=${DRONE_BRANCH}-${DRONE_COMMIT}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDomain/
- ln happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} happydomain
- go build -tags netgo,swagger,web -ldflags '-w -X "main.Version=${DRONE_BRANCH}-${DRONE_COMMIT}" -X main.build=${DRONE_BUILD_NUMBER}' -o deploy/happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDomain/
- ln deploy/happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} happydomain
environment:
CGO_ENABLED: 0
when:
@ -307,8 +243,8 @@ steps:
image: golang:1-alpine
commands:
- apk add --no-cache git
- go build -tags netgo,swagger,web -ldflags '-w -X main.Version=${DRONE_TAG##v} -X main.build=${DRONE_BUILD_NUMBER}' -o happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDomain/
- ln happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} happydomain
- go build -tags netgo,swagger,web -ldflags '-w -X main.Version=${DRONE_TAG##v} -X main.build=${DRONE_BUILD_NUMBER}' -o deploy/happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDomain/
- ln deploy/happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} happydomain
environment:
CGO_ENABLED: 0
when:
@ -324,6 +260,33 @@ steps:
environment:
CGO_ENABLED: 0
- name: build-commit macOS
image: golang:1-alpine
commands:
- apk add --no-cache git
- go build -tags netgo,swagger,web -ldflags '-w -X "main.Version=${DRONE_BRANCH}-${DRONE_COMMIT}" -X main.build=${DRONE_BUILD_NUMBER}' -o deploy/happydomain-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDomain/
environment:
CGO_ENABLED: 0
GOOS: darwin
GOARCH: arm64
when:
event:
exclude:
- tag
- name: build-tag macOS
image: golang:1-alpine
commands:
- apk add --no-cache git
- go build -tags netgo,swagger,web -ldflags '-w -X "main.Version=${DRONE_TAG##v}" -X main.build=${DRONE_BUILD_NUMBER}' -o deploy/happydomain-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDomain/
environment:
CGO_ENABLED: 0
GOOS: darwin
GOARCH: arm64
when:
event:
- tag
- name: deploy
image: plugins/s3
settings:
@ -335,8 +298,9 @@ steps:
from_secret: s3_access_key
secret_key:
from_secret: s3_secret_key
source: happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
source: deploy/*
target: /${DRONE_BRANCH//\//-}/
strip_prefix: deploy/
when:
event:
- push
@ -355,72 +319,9 @@ steps:
from_secret: s3_access_key
secret_key:
from_secret: s3_secret_key
source: happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
target: /${DRONE_TAG}/
when:
event:
- tag
- name: build-commit macOS
image: golang:1-alpine
commands:
- apk add --no-cache git
- go build -tags netgo,swagger,web -ldflags '-w -X "main.Version=${DRONE_BRANCH}-${DRONE_COMMIT}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydomain-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDomain/
environment:
CGO_ENABLED: 0
GOOS: darwin
GOARCH: arm64
when:
event:
exclude:
- tag
- name: build-tag macOS
image: golang:1-alpine
commands:
- apk add --no-cache git
- go build -tags netgo,swagger,web -ldflags '-w -X "main.Version=${DRONE_TAG##v}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydomain-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDomain/
environment:
CGO_ENABLED: 0
GOOS: darwin
GOARCH: arm64
when:
event:
- tag
- name: deploy macOS
image: plugins/s3
settings:
endpoint: https://blob.nemunai.re
path_style: true
region: garage
bucket: happydomain-dl
access_key:
from_secret: s3_access_key
secret_key:
from_secret: s3_secret_key
source: happydomain-darwin-${DRONE_STAGE_ARCH}
target: /${DRONE_BRANCH//\//-}/
when:
event:
- push
branch:
exclude:
- renovate/*
- name: deploy macOS release
image: plugins/s3
settings:
endpoint: https://blob.nemunai.re
path_style: true
region: garage
bucket: happydomain-dl
access_key:
from_secret: s3_access_key
secret_key:
from_secret: s3_secret_key
source: happydomain-darwin-${DRONE_STAGE_ARCH}
source: deploy/*
target: /${DRONE_TAG}/
strip_prefix: deploy/
when:
event:
- tag

View file

@ -290,6 +290,37 @@ Contributions are welcome! Here's how you can help:
- **Share feedback:** [Tell us what you think](https://feedback.happydomain.org/), your input guides the project.
AI Disclaimer
-------------
There have been questions about AI usage in project development. Our project handles domain name management, a sensitive area where mistakes can cause real outages, it's important to explain how AI is used in the development process.
AI is used as a helper for:
- verification of code quality and searching for vulnerabilities
- cleaning up and improving documentation, comments and code
- assistance during development
- double-checking PRs and commits after human review
AI is not used for:
- writing entire features or components
- "vibe coding" approach
- code without line-by-line verification by a human
- code without tests
The project has:
- CI/CD pipeline automation with tests and linting to ensure code quality
- verification by experienced developers
So AI is just an assistant and a tool for developers to increase productivity and ensure code quality. The work is done by developers.
We do not differentiate between bad human code and AI vibe code. There are strict requirements for any code to be merged to keep the codebase maintainable. Even if code is written manually by a human, it's not guaranteed to be merged. Vibe code is not allowed and such PRs are rejected.
*Inspired by the [Databasus AI disclaimer](https://github.com/databasus/databasus#ai-disclaimer).*
License
-------

64
SECURITY.md Normal file
View file

@ -0,0 +1,64 @@
# Security Policy
## Supported Versions
Only the latest version of happyDomain is supported with security fixes.
| Version | Supported |
| ------- | --------- |
| latest | ✓ |
| < latest| ✗ |
## Scope
### In scope
- happyDomain application code (API/backend and web frontend)
- Other websites directly operated by the happyDomain team: documentation, main website, blog, git redirection, downloads website, demo instance, insights
### Out of scope
- Vulnerabilities in third-party dependencies that are not directly exploitable in happyDomain
- Social engineering attacks
- Denial-of-service attacks requiring significant resources
## Reporting a Vulnerability
If you discover a security vulnerability in happyDomain, please report it privately.
By email: security@happydomain.org
On GitHub: https://github.com/happydomain/happydomain/security/advisories
On Gitlab: https://gitlab.com/happyDomain/happyDomain/-/issues/new (check Confidential issue before submitting)
On Framagit: https://framagit.org/happyDomain/happyDomain/-/issues/new (check Confidential issue before submitting)
Please include:
- description of the vulnerability
- steps to reproduce
- potential impact
## Disclosure policy
We follow a responsible disclosure process.
After receiving a report we will:
1. acknowledge within 72 hours
2. investigate the issue
3. prepare a fix
4. publish a security advisory when the fix is available
## Safe Harbor
We consider security research conducted in good faith to be authorized. We will not pursue legal action against researchers who:
- Report vulnerabilities through the channels listed above
- Avoid accessing, modifying, or deleting data that doesn't belong to them
- Avoid degrading the availability of our services
- Do not publicly disclose the vulnerability before a fix is available
## Credits
We are happy to credit security researchers who responsibly disclose vulnerabilities.

84
checks/interface.go Normal file
View file

@ -0,0 +1,84 @@
// 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
// Package checks provides the registry for domain health checkers.
// It allows individual checker implementations to self-register at startup
// via init() functions and exposes functions to retrieve registered checkers.
package checks // import "git.happydns.org/happyDomain/checks"
import (
"encoding/json"
"fmt"
"log"
"git.happydns.org/happyDomain/model"
)
// checkersList is the ordered list of all registered checks.
var checkersList map[string]happydns.Checker = map[string]happydns.Checker{}
// RegisterChecker declares the existence of the given check. It is intended to
// be called from init() functions in individual check files so that each check
// self-registers at program startup.
//
// If two checks try to register the same environment name the program will
// terminate: name collisions are a configuration error, not a runtime one.
func RegisterChecker(name string, checker happydns.Checker) {
log.Println("Registering new checker:")
checkersList[name] = checker
}
// GetCheckers returns the ordered list of all registered checks.
func GetCheckers() *map[string]happydns.Checker {
return &checkersList
}
// FindChecker returns the check registered under the given environment name,
// or an error if no check with that name exists.
func FindChecker(name string) (happydns.Checker, error) {
c, ok := checkersList[name]
if !ok {
return nil, fmt.Errorf("unable to find check %q", name)
}
return c, nil
}
// GetHTMLReport renders an HTML report for the given checker and raw JSON report data.
// Returns (html, true, nil) if the checker supports HTML reports, or ("", false, nil) if not.
func GetHTMLReport(checker happydns.Checker, raw json.RawMessage) (string, bool, error) {
hr, ok := checker.(happydns.CheckerHTMLReporter)
if !ok {
return "", false, nil
}
html, err := hr.GetHTMLReport(raw)
return html, true, err
}
// GetMetrics extracts time-series metrics from a slice of check results.
// Returns (report, true, nil) if the checker supports metrics, or (nil, false, nil) if not.
func GetMetrics(checker happydns.Checker, results []*happydns.CheckResult) (*happydns.MetricsReport, bool, error) {
mr, ok := checker.(happydns.CheckerMetricsReporter)
if !ok {
return nil, false, nil
}
report, err := mr.ExtractMetrics(results)
return report, true, err
}

318
checks/ping.go Normal file
View file

@ -0,0 +1,318 @@
// 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
package checks
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"strings"
"time"
probing "github.com/prometheus-community/pro-bing"
"git.happydns.org/happyDomain/model"
"git.happydns.org/happyDomain/services/abstract"
)
func init() {
RegisterChecker("ping", &PingCheck{})
}
// PingReport contains the results of a ping check across one or more targets.
type PingReport struct {
Targets []PingTargetResult `json:"targets"`
}
// PingTargetResult contains the ping statistics for a single IP address.
type PingTargetResult struct {
Address string `json:"address"`
RTTMin float64 `json:"rtt_min"`
RTTAvg float64 `json:"rtt_avg"`
RTTMax float64 `json:"rtt_max"`
PacketLoss float64 `json:"packet_loss"`
Sent int `json:"sent"`
Received int `json:"received"`
}
type PingCheck struct{}
func (p *PingCheck) ID() string {
return "ping"
}
func (p *PingCheck) Name() string {
return "Ping (ICMP)"
}
func (p *PingCheck) Availability() happydns.CheckerAvailability {
return happydns.CheckerAvailability{
ApplyToService: true,
LimitToServices: []string{"abstract.Server"},
}
}
func (p *PingCheck) Options() happydns.CheckerOptionsDocumentation {
return happydns.CheckerOptionsDocumentation{
ServiceOpts: []happydns.CheckerOptionDocumentation{
{
Id: "warningRTT",
Type: "number",
Label: "Warning RTT threshold (ms)",
Default: float64(100),
},
{
Id: "criticalRTT",
Type: "number",
Label: "Critical RTT threshold (ms)",
Default: float64(500),
},
{
Id: "warningPacketLoss",
Type: "number",
Label: "Warning packet loss threshold (%)",
Default: float64(10),
},
{
Id: "criticalPacketLoss",
Type: "number",
Label: "Critical packet loss threshold (%)",
Default: float64(50),
},
{
Id: "count",
Type: "number",
Label: "Number of pings to send",
Default: float64(5),
},
},
RunOpts: []happydns.CheckerOptionDocumentation{
{
Id: "service",
Label: "Service",
AutoFill: happydns.AutoFillService,
},
},
}
}
func getFloatOption(options happydns.CheckerOptions, key string, defaultVal float64) float64 {
v, ok := options[key]
if !ok {
return defaultVal
}
switch val := v.(type) {
case float64:
return val
case json.Number:
f, err := val.Float64()
if err != nil {
return defaultVal
}
return f
default:
return defaultVal
}
}
func getIntOption(options happydns.CheckerOptions, key string, defaultVal int) int {
return int(getFloatOption(options, key, float64(defaultVal)))
}
// ipsFromServiceOption extracts the IP addresses directly from the auto-filled
// service body (abstract.Server). Only IPs actually present in the service
// definition are returned, so an IPv4-only service won't trigger IPv6 pings.
// The service JSON has the shape:
//
// {"_svctype":"abstract.Server","Service":{"A":{...},"AAAA":{...}}}
func ipsFromServiceOption(svc *happydns.ServiceMessage) []net.IP {
var server abstract.Server
if err := json.Unmarshal(svc.Service, &server); err != nil {
return nil
}
var ips []net.IP
if server.A != nil && len(server.A.A) > 0 {
ips = append(ips, server.A.A)
}
if server.AAAA != nil && len(server.AAAA.AAAA) > 0 {
ips = append(ips, server.AAAA.AAAA)
}
return ips
}
func (p *PingCheck) RunCheck(ctx context.Context, options happydns.CheckerOptions, meta map[string]string) (*happydns.CheckResult, error) {
service, ok := options["service"].(*happydns.ServiceMessage)
if !ok {
return nil, fmt.Errorf("service not defined")
}
if service.Type != "abstract.Server" {
return nil, fmt.Errorf("service is %s, expected abstract.Server", service.Type)
}
warningRTT := getFloatOption(options, "warningRTT", 100)
criticalRTT := getFloatOption(options, "criticalRTT", 500)
warningPacketLoss := getFloatOption(options, "warningPacketLoss", 10)
criticalPacketLoss := getFloatOption(options, "criticalPacketLoss", 50)
count := getIntOption(options, "count", 5)
if count < 1 {
count = 1
}
if count > 20 {
count = 20
}
// Prefer IPs from the service definition; fall back to live DNS.
// Using service IPs avoids pinging addresses not defined in the service
// (e.g. live IPv6 records that the service doesn't have).
var rawIPs []net.IP
if serviceIPs := ipsFromServiceOption(service); len(serviceIPs) > 0 {
rawIPs = serviceIPs
}
if len(rawIPs) == 0 {
return nil, fmt.Errorf("no IP addresses found for %s", service.Domain)
}
report := PingReport{}
var overallStatus happydns.CheckResultStatus = happydns.CheckResultStatusOK
var summaryParts []string
var errs []error
for _, ip := range rawIPs {
addr := ip.String()
pinger, err := probing.NewPinger(addr)
if err != nil {
errs = append(errs, fmt.Errorf("failed to create pinger for %s: %w", addr, err))
continue
}
pinger.Count = count
pinger.Timeout = time.Duration(count)*time.Second + 5*time.Second
if err = pinger.RunWithContext(ctx); err != nil {
errs = append(errs, fmt.Errorf("ping failed for %s: %w", addr, err))
continue
}
stats := pinger.Statistics()
target := PingTargetResult{
Address: addr,
RTTMin: float64(stats.MinRtt.Microseconds()) / 1000.0,
RTTAvg: float64(stats.AvgRtt.Microseconds()) / 1000.0,
RTTMax: float64(stats.MaxRtt.Microseconds()) / 1000.0,
PacketLoss: stats.PacketLoss,
Sent: stats.PacketsSent,
Received: stats.PacketsRecv,
}
report.Targets = append(report.Targets, target)
if target.PacketLoss >= criticalPacketLoss || target.RTTAvg >= criticalRTT {
overallStatus = happydns.CheckResultStatusCritical
} else if (target.PacketLoss >= warningPacketLoss || target.RTTAvg >= warningRTT) && overallStatus > happydns.CheckResultStatusWarn {
overallStatus = happydns.CheckResultStatusWarn
}
summaryParts = append(summaryParts, fmt.Sprintf("%s: %.1fms avg, %.0f%% loss", addr, target.RTTAvg, target.PacketLoss))
}
// If no IP responded at all, return the combined errors as a fatal error.
if len(report.Targets) == 0 {
return nil, errors.Join(errs...)
}
return &happydns.CheckResult{
Status: overallStatus,
StatusLine: strings.Join(summaryParts, " | "),
Report: report,
}, errors.Join(errs...)
}
// ExtractMetrics implements happydns.CheckerMetricsReporter.
func (p *PingCheck) ExtractMetrics(results []*happydns.CheckResult) (*happydns.MetricsReport, error) {
type seriesKey struct {
metric string
address string
}
seriesMap := map[seriesKey]*happydns.MetricSeries{}
var seriesOrder []seriesKey
for _, result := range results {
if result.Report == nil {
continue
}
raw, err := json.Marshal(result.Report)
if err != nil {
continue
}
var report PingReport
if err := json.Unmarshal(raw, &report); err != nil {
continue
}
for _, target := range report.Targets {
ts := result.ExecutedAt
metrics := []struct {
suffix string
label string
unit string
value float64
}{
{"rtt_avg", "RTT Avg", "ms", target.RTTAvg},
{"rtt_min", "RTT Min", "ms", target.RTTMin},
{"rtt_max", "RTT Max", "ms", target.RTTMax},
{"packet_loss", "Packet Loss", "%", target.PacketLoss},
}
for _, m := range metrics {
key := seriesKey{metric: m.suffix, address: target.Address}
s, exists := seriesMap[key]
if !exists {
s = &happydns.MetricSeries{
Name: fmt.Sprintf("%s_%s", m.suffix, target.Address),
Label: fmt.Sprintf("%s (%s)", m.label, target.Address),
Unit: m.unit,
}
seriesMap[key] = s
seriesOrder = append(seriesOrder, key)
}
s.Points = append(s.Points, happydns.MetricPoint{
Timestamp: ts,
Value: m.value,
})
}
}
}
var series []happydns.MetricSeries
for _, key := range seriesOrder {
series = append(series, *seriesMap[key])
}
return &happydns.MetricsReport{Series: series}, nil
}

607
checks/zonemaster.go Normal file
View file

@ -0,0 +1,607 @@
package checks
import (
"bytes"
"context"
"encoding/json"
"fmt"
"html/template"
"io"
"net/http"
"sort"
"strings"
"time"
"git.happydns.org/happyDomain/model"
)
func init() {
RegisterChecker("zonemaster", &ZonemasterCheck{})
}
type ZonemasterCheck struct{}
func (p *ZonemasterCheck) ID() string {
return "zonemaster"
}
func (p *ZonemasterCheck) Name() string {
return "Zonemaster"
}
func (p *ZonemasterCheck) Availability() happydns.CheckerAvailability {
return happydns.CheckerAvailability{
ApplyToDomain: true,
}
}
func (p *ZonemasterCheck) Options() happydns.CheckerOptionsDocumentation {
return happydns.CheckerOptionsDocumentation{
RunOpts: []happydns.CheckerOptionDocumentation{
{
Id: "domainName",
Type: "string",
Label: "Domain name to check",
AutoFill: happydns.AutoFillDomainName,
Required: true,
},
{
Id: "profile",
Type: "string",
Label: "Profile",
Placeholder: "default",
Default: "default",
},
},
UserOpts: []happydns.CheckerOptionDocumentation{
{
Id: "language",
Type: "select",
Label: "Result language",
Default: "en",
Choices: []string{
"en", // English
"fr", // French
"de", // German
"es", // Spanish
"sv", // Swedish
"da", // Danish
"fi", // Finnish
"nb", // Norwegian Bokmål
"nl", // Dutch
"pt", // Portuguese
},
},
},
AdminOpts: []happydns.CheckerOptionDocumentation{
{
Id: "zonemasterAPIURL",
Type: "string",
Label: "Zonemaster API URL",
Placeholder: "https://zonemaster.net/api",
Default: "https://zonemaster.net/api",
},
},
}
}
// JSON-RPC request/response structures
type jsonRPCRequest struct {
Jsonrpc string `json:"jsonrpc"`
Method string `json:"method"`
Params any `json:"params"`
ID int `json:"id"`
}
type jsonRPCResponse struct {
Jsonrpc string `json:"jsonrpc"`
Result json.RawMessage `json:"result,omitempty"`
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error,omitempty"`
ID int `json:"id"`
}
// Zonemaster API structures
type startTestParams struct {
Domain string `json:"domain"`
Profile string `json:"profile,omitempty"`
IPv4 bool `json:"ipv4,omitempty"`
IPv6 bool `json:"ipv6,omitempty"`
}
type testProgressParams struct {
TestID string `json:"test_id"`
}
type getResultsParams struct {
ID string `json:"id"`
Language string `json:"language"`
}
type testResult struct {
Module string `json:"module"`
Message string `json:"message"`
Level string `json:"level"`
Testcase string `json:"testcase,omitempty"`
}
type zonemasterResults struct {
CreatedAt string `json:"created_at"`
HashID string `json:"hash_id"`
Language string `json:"language,omitempty"`
Params map[string]any `json:"params"`
Results []testResult `json:"results"`
TestcaseDescriptions map[string]string `json:"testcase_descriptions,omitempty"`
}
func (p *ZonemasterCheck) callJSONRPC(ctx context.Context, apiURL, method string, params any) (json.RawMessage, error) {
reqBody := jsonRPCRequest{
Jsonrpc: "2.0",
Method: method,
Params: params,
ID: 1,
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to call API: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
}
var rpcResp jsonRPCResponse
if err := json.NewDecoder(resp.Body).Decode(&rpcResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
if rpcResp.Error != nil {
return nil, fmt.Errorf("API error %d: %s", rpcResp.Error.Code, rpcResp.Error.Message)
}
return rpcResp.Result, nil
}
func (p *ZonemasterCheck) RunCheck(ctx context.Context, options happydns.CheckerOptions, meta map[string]string) (*happydns.CheckResult, error) {
// Extract options
domainName, ok := options["domainName"].(string)
if !ok || domainName == "" {
return nil, fmt.Errorf("domainName is required")
}
domainName = strings.TrimSuffix(domainName, ".")
apiURL, ok := options["zonemasterAPIURL"].(string)
if !ok || apiURL == "" {
return nil, fmt.Errorf("zonemasterAPIURL is required")
}
apiURL = strings.TrimSuffix(apiURL, "/")
language := "en"
if lang, ok := options["language"].(string); ok && lang != "" {
language = lang
}
profile := "default"
if prof, ok := options["profile"].(string); ok && prof != "" {
profile = prof
}
// Step 1: Start the test
startParams := startTestParams{
Domain: domainName,
Profile: profile,
IPv4: true,
IPv6: true,
}
result, err := p.callJSONRPC(ctx, apiURL, "start_domain_test", startParams)
if err != nil {
return nil, fmt.Errorf("failed to start test: %w", err)
}
var testID string
if err = json.Unmarshal(result, &testID); err != nil {
return nil, fmt.Errorf("failed to parse test ID: %w", err)
}
if testID == "" {
return nil, fmt.Errorf("received empty test ID")
}
// Step 2: Poll for test completion
progressParams := testProgressParams{TestID: testID}
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil, fmt.Errorf("test cancelled (test ID: %s): %w", testID, ctx.Err())
case <-ticker.C:
result, err := p.callJSONRPC(ctx, apiURL, "test_progress", progressParams)
if err != nil {
return nil, fmt.Errorf("failed to test progress: %w", err)
}
var progress float64
if err := json.Unmarshal(result, &progress); err != nil {
return nil, fmt.Errorf("failed to parse progress: %w", err)
}
if progress >= 100 {
goto testComplete
}
}
}
testComplete:
// Step 3: Get test results
resultsParams := getResultsParams{
ID: testID,
Language: language,
}
result, err = p.callJSONRPC(ctx, apiURL, "get_test_results", resultsParams)
if err != nil {
return nil, fmt.Errorf("failed to get results: %w", err)
}
var results zonemasterResults
if err := json.Unmarshal(result, &results); err != nil {
return nil, fmt.Errorf("failed to parse results: %w", err)
}
results.Language = language
// Analyze results to determine overall status
var (
errorCount int
warningCount int
infoCount int
criticalMsgs []string
)
for _, r := range results.Results {
switch strings.ToUpper(r.Level) {
case "CRITICAL", "ERROR":
errorCount++
if len(criticalMsgs) < 5 { // Keep first 5 critical messages
criticalMsgs = append(criticalMsgs, r.Message)
}
case "WARNING":
warningCount++
case "INFO", "NOTICE":
infoCount++
}
}
// Determine status
var status happydns.CheckResultStatus
var statusLine string
if errorCount > 0 {
status = happydns.CheckResultStatusCritical
statusLine = fmt.Sprintf("%d error(s), %d warning(s) found", errorCount, warningCount)
if len(criticalMsgs) > 0 {
statusLine += ": " + strings.Join(criticalMsgs[:min(2, len(criticalMsgs))], "; ")
}
} else if warningCount > 0 {
status = happydns.CheckResultStatusWarn
statusLine = fmt.Sprintf("%d warning(s) found", warningCount)
} else {
status = happydns.CheckResultStatusOK
statusLine = fmt.Sprintf("All checks passed (%d checks)", len(results.Results))
}
return &happydns.CheckResult{
Status: status,
StatusLine: statusLine,
Report: results,
}, nil
}
// ── HTML report ───────────────────────────────────────────────────────────────
// zmLevelDisplayOrder defines the severity order used for sorting and display.
var zmLevelDisplayOrder = []string{"CRITICAL", "ERROR", "WARNING", "NOTICE", "INFO", "DEBUG"}
var zmLevelRank = func() map[string]int {
m := make(map[string]int, len(zmLevelDisplayOrder))
for i, l := range zmLevelDisplayOrder {
m[l] = len(zmLevelDisplayOrder) - i
}
return m
}()
type zmLevelCount struct {
Level string
Count int
}
type zmModuleGroup struct {
Name string
Position int // first-seen index, used as tiebreaker in sort
Results []testResult
Levels []zmLevelCount // sorted by severity desc, zeros omitted
Worst string
Open bool
}
type zmTemplateData struct {
Domain string
CreatedAt string
HashID string
Language string
Modules []zmModuleGroup
Totals []zmLevelCount // sorted by severity desc, zeros omitted
}
var zonemasterHTMLTemplate = template.Must(
template.New("zonemaster").
Funcs(template.FuncMap{
"badgeClass": func(level string) string {
switch strings.ToUpper(level) {
case "CRITICAL":
return "badge-critical"
case "ERROR":
return "badge-error"
case "WARNING":
return "badge-warning"
case "NOTICE":
return "badge-notice"
case "INFO":
return "badge-info"
default:
return "badge-debug"
}
},
}).
Parse(`<!DOCTYPE html>
<html lang="{{.Language}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Zonemaster{{if .Domain}} {{.Domain}}{{end}}</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
:root {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
line-height: 1.5;
color: #1f2937;
background: #f3f4f6;
}
body { margin: 0; padding: 1rem; }
a { color: inherit; }
code { font-family: ui-monospace, monospace; font-size: .9em; }
/* Header card */
.hd {
background: #fff;
border-radius: 10px;
padding: 1rem 1.25rem 1.1rem;
margin-bottom: .75rem;
box-shadow: 0 1px 3px rgba(0,0,0,.08);
}
.hd h1 { margin: 0 0 .2rem; font-size: 1.15rem; font-weight: 700; }
.hd .meta { color: #6b7280; font-size: .82rem; margin-bottom: .6rem; }
.totals { display: flex; gap: .35rem; flex-wrap: wrap; }
/* Badges */
.badge {
display: inline-flex; align-items: center;
padding: .18em .55em;
border-radius: 9999px;
font-size: .72rem; font-weight: 700;
letter-spacing: .02em; white-space: nowrap;
}
.badge-critical { background: #fee2e2; color: #991b1b; }
.badge-error { background: #ffedd5; color: #9a3412; }
.badge-warning { background: #fef3c7; color: #92400e; }
.badge-notice { background: #e0f2fe; color: #075985; }
.badge-info { background: #dbeafe; color: #1e40af; }
.badge-debug { background: #f3f4f6; color: #4b5563; }
/* Accordion */
details {
background: #fff;
border-radius: 8px;
margin-bottom: .45rem;
box-shadow: 0 1px 3px rgba(0,0,0,.07);
overflow: hidden;
}
summary {
display: flex; align-items: center; gap: .5rem;
padding: .65rem 1rem;
cursor: pointer;
user-select: none;
list-style: none;
}
summary::-webkit-details-marker { display: none; }
summary::before {
content: "▶";
font-size: .65rem;
color: #9ca3af;
transition: transform .15s;
flex-shrink: 0;
}
details[open] > summary::before { transform: rotate(90deg); }
.mod-name { font-weight: 600; flex: 1; font-size: .9rem; }
.mod-badges { display: flex; gap: .25rem; flex-wrap: wrap; }
/* Result rows */
.results { border-top: 1px solid #f3f4f6; }
.row {
display: grid;
grid-template-columns: max-content 1fr;
gap: .6rem;
padding: .45rem 1rem;
border-bottom: 1px solid #f9fafb;
align-items: start;
}
.row:last-child { border-bottom: none; }
.row-msg { color: #374151; }
.row-tc { font-size: .75rem; color: #9ca3af; }
</style>
</head>
<body>
<div class="hd">
<h1>Zonemaster{{if .Domain}} <code>{{.Domain}}</code>{{end}}</h1>
<div class="meta">
{{- if .CreatedAt}}Run at {{.CreatedAt}}{{end -}}
{{- if and .CreatedAt .HashID}} &middot; {{end -}}
{{- if .HashID}}ID: <code>{{.HashID}}</code>{{end -}}
</div>
<div class="totals">
{{- range .Totals}}
<span class="badge {{badgeClass .Level}}">{{.Level}}&nbsp;{{.Count}}</span>
{{- end}}
</div>
</div>
{{range .Modules -}}
<details{{if .Open}} open{{end}}>
<summary>
<span class="mod-name">{{.Name}}</span>
<span class="mod-badges">
{{- range .Levels}}
<span class="badge {{badgeClass .Level}}">{{.Count}}</span>
{{- end}}
</span>
</summary>
<div class="results">
{{- range .Results}}
<div class="row">
<span class="badge {{badgeClass .Level}}">{{.Level}}</span>
<div>
<div class="row-msg">{{.Message}}</div>
{{- if .Testcase}}<div class="row-tc">{{.Testcase}}</div>{{end}}
</div>
</div>
{{- end}}
</div>
</details>
{{end -}}
</body>
</html>`),
)
// GetHTMLReport implements happydns.CheckerHTMLReporter.
func (p *ZonemasterCheck) GetHTMLReport(raw json.RawMessage) (string, error) {
var results zonemasterResults
if err := json.Unmarshal(raw, &results); err != nil {
return "", fmt.Errorf("failed to unmarshal zonemaster results: %w", err)
}
// Group results by module, preserving first-seen order.
moduleOrder := []string{}
moduleMap := map[string][]testResult{}
for _, r := range results.Results {
if _, seen := moduleMap[r.Module]; !seen {
moduleOrder = append(moduleOrder, r.Module)
}
moduleMap[r.Module] = append(moduleMap[r.Module], r)
}
totalCounts := map[string]int{}
var modules []zmModuleGroup
for _, name := range moduleOrder {
rs := moduleMap[name]
counts := map[string]int{}
for _, r := range rs {
lvl := strings.ToUpper(r.Level)
counts[lvl]++
totalCounts[lvl]++
}
// Find worst level and build sorted level-count slice.
worst := ""
worstRank := -1
var levels []zmLevelCount
for _, l := range zmLevelDisplayOrder {
if n, ok := counts[l]; ok && n > 0 {
levels = append(levels, zmLevelCount{Level: l, Count: n})
if zmLevelRank[l] > worstRank {
worstRank = zmLevelRank[l]
worst = l
}
}
}
// Append any unknown levels last.
for l, n := range counts {
if _, known := zmLevelRank[l]; !known {
levels = append(levels, zmLevelCount{Level: l, Count: n})
}
}
modules = append(modules, zmModuleGroup{
Name: name,
Position: len(modules),
Results: rs,
Levels: levels,
Worst: worst,
Open: worst == "CRITICAL" || worst == "ERROR",
})
}
// Sort modules: most severe first, then by original appearance order.
sort.Slice(modules, func(i, j int) bool {
ri, rj := zmLevelRank[modules[i].Worst], zmLevelRank[modules[j].Worst]
if ri != rj {
return ri > rj
}
return modules[i].Position < modules[j].Position
})
// Build sorted totals slice.
var totals []zmLevelCount
for _, l := range zmLevelDisplayOrder {
if n, ok := totalCounts[l]; ok && n > 0 {
totals = append(totals, zmLevelCount{Level: l, Count: n})
}
}
domain := ""
if d, ok := results.Params["domain"]; ok {
domain = fmt.Sprintf("%v", d)
}
lang := results.Language
if lang == "" {
lang = "en"
}
data := zmTemplateData{
Domain: domain,
CreatedAt: results.CreatedAt,
HashID: results.HashID,
Language: lang,
Modules: modules,
Totals: totals,
}
var buf strings.Builder
if err := zonemasterHTMLTemplate.Execute(&buf, data); err != nil {
return "", fmt.Errorf("failed to render zonemaster HTML report: %w", err)
}
return buf.String(), nil
}

1
go.mod
View file

@ -21,6 +21,7 @@ require (
github.com/mileusna/useragent v1.3.5
github.com/oracle/nosql-go-sdk v1.4.7
github.com/ovh/go-ovh v1.9.0
github.com/prometheus-community/pro-bing v0.8.0
github.com/rrivera/identicon v0.0.0-20240116195454-d5ba35832c0d
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.1

2
go.sum
View file

@ -509,6 +509,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=

View file

@ -22,6 +22,7 @@
package adapter
import (
"crypto/sha256"
"strings"
dnscontrol "github.com/StackExchange/dnscontrol/v4/models"
@ -88,9 +89,24 @@ func DNSControlDiffByRecord(oldrrs []happydns.Record, newrrs []happydns.Record,
ret := make([]*happydns.Correction, len(corrections))
for i, correction := range corrections {
id := sha256.Sum224([]byte(correction.MsgsJoined))
var oldRecords []happydns.Record
for _, rc := range correction.Old {
oldRecords = append(oldRecords, rc.ToRR())
}
var newRecords []happydns.Record
for _, rc := range correction.New {
newRecords = append(newRecords, rc.ToRR())
}
ret[i] = &happydns.Correction{
Msg: correction.MsgsJoined,
Kind: DNSControlFromCorrectionType(correction.Type),
Id: id[:],
Msg: correction.MsgsJoined,
Kind: DNSControlFromCorrectionType(correction.Type),
OldRecords: oldRecords,
NewRecords: newRecords,
}
}
@ -131,3 +147,71 @@ func NewDNSControlDomainConfig(origin string, rrs []happydns.Record) (*dnscontro
Records: records,
}, err
}
// recordKey returns a canonical string for matching records by name, type, and rdata.
func recordKey(r happydns.Record) string {
return r.String()
}
// BuildTargetRecords computes the target record set by applying the selected
// corrections to the provider's current records. It starts with a copy of
// providerRecords, then for each correction whose ID is in selectedIDs:
// - Addition: appends NewRecords
// - Deletion: removes matching OldRecords
// - Update: removes matching OldRecords, appends NewRecords
func BuildTargetRecords(
providerRecords []happydns.Record,
corrections []*happydns.Correction,
selectedIDs []happydns.Identifier,
) []happydns.Record {
// Build a set of selected IDs for fast lookup.
selected := make(map[string]bool, len(selectedIDs))
for _, id := range selectedIDs {
selected[string(id)] = true
}
// Start with a copy of provider records.
result := make([]happydns.Record, len(providerRecords))
copy(result, providerRecords)
for _, cr := range corrections {
if !selected[string(cr.Id)] {
continue
}
switch cr.Kind {
case happydns.CorrectionKindAddition:
result = append(result, cr.NewRecords...)
case happydns.CorrectionKindDeletion:
result = removeRecords(result, cr.OldRecords)
case happydns.CorrectionKindUpdate:
result = removeRecords(result, cr.OldRecords)
result = append(result, cr.NewRecords...)
}
}
return result
}
// removeRecords removes records from the slice that match any of the toRemove
// records by their canonical string representation. Each toRemove record
// removes at most one match.
func removeRecords(records []happydns.Record, toRemove []happydns.Record) []happydns.Record {
removeKeys := make(map[string]int, len(toRemove))
for _, r := range toRemove {
removeKeys[recordKey(r)]++
}
result := make([]happydns.Record, 0, len(records))
for _, r := range records {
key := recordKey(r)
if removeKeys[key] > 0 {
removeKeys[key]--
continue
}
result = append(result, r)
}
return result
}

View file

@ -0,0 +1,279 @@
// 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
package adapter_test
import (
"net"
"testing"
"github.com/miekg/dns"
adapter "git.happydns.org/happyDomain/internal/adapters"
"git.happydns.org/happyDomain/model"
)
func makeA(name string, ip string) happydns.Record {
return &dns.A{
Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 300},
A: net.ParseIP(ip),
}
}
func makeMX(name string, pref uint16, mx string) happydns.Record {
return &dns.MX{
Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeMX, Class: dns.ClassINET, Ttl: 300},
Preference: pref,
Mx: mx,
}
}
func TestBuildTargetRecords_AllSelected(t *testing.T) {
providerRecords := []happydns.Record{
makeA("example.com.", "1.2.3.4"),
}
newRecord := makeA("example.com.", "5.6.7.8")
corrections := []*happydns.Correction{
{
Id: happydns.Identifier([]byte("add-1")),
Kind: happydns.CorrectionKindAddition,
NewRecords: []happydns.Record{newRecord},
},
}
selectedIDs := []happydns.Identifier{
happydns.Identifier([]byte("add-1")),
}
result := adapter.BuildTargetRecords(providerRecords, corrections, selectedIDs)
if len(result) != 2 {
t.Fatalf("expected 2 records, got %d", len(result))
}
}
func TestBuildTargetRecords_NoneSelected(t *testing.T) {
providerRecords := []happydns.Record{
makeA("example.com.", "1.2.3.4"),
}
corrections := []*happydns.Correction{
{
Id: happydns.Identifier([]byte("add-1")),
Kind: happydns.CorrectionKindAddition,
NewRecords: []happydns.Record{makeA("example.com.", "5.6.7.8")},
},
}
result := adapter.BuildTargetRecords(providerRecords, corrections, nil)
if len(result) != 1 {
t.Fatalf("expected 1 record, got %d", len(result))
}
if result[0].String() != providerRecords[0].String() {
t.Errorf("expected unchanged provider record, got %s", result[0].String())
}
}
func TestBuildTargetRecords_Deletion(t *testing.T) {
providerRecords := []happydns.Record{
makeA("example.com.", "1.2.3.4"),
makeA("example.com.", "5.6.7.8"),
}
corrections := []*happydns.Correction{
{
Id: happydns.Identifier([]byte("del-1")),
Kind: happydns.CorrectionKindDeletion,
OldRecords: []happydns.Record{makeA("example.com.", "1.2.3.4")},
},
}
selectedIDs := []happydns.Identifier{
happydns.Identifier([]byte("del-1")),
}
result := adapter.BuildTargetRecords(providerRecords, corrections, selectedIDs)
if len(result) != 1 {
t.Fatalf("expected 1 record, got %d", len(result))
}
if result[0].String() != providerRecords[1].String() {
t.Errorf("expected remaining record %s, got %s", providerRecords[1].String(), result[0].String())
}
}
func TestBuildTargetRecords_Update(t *testing.T) {
oldRecord := makeA("example.com.", "1.2.3.4")
newRecord := makeA("example.com.", "9.8.7.6")
providerRecords := []happydns.Record{oldRecord}
corrections := []*happydns.Correction{
{
Id: happydns.Identifier([]byte("upd-1")),
Kind: happydns.CorrectionKindUpdate,
OldRecords: []happydns.Record{oldRecord},
NewRecords: []happydns.Record{newRecord},
},
}
selectedIDs := []happydns.Identifier{
happydns.Identifier([]byte("upd-1")),
}
result := adapter.BuildTargetRecords(providerRecords, corrections, selectedIDs)
if len(result) != 1 {
t.Fatalf("expected 1 record, got %d", len(result))
}
if result[0].String() != newRecord.String() {
t.Errorf("expected updated record %s, got %s", newRecord.String(), result[0].String())
}
}
func TestBuildTargetRecords_PartialSelection(t *testing.T) {
providerRecords := []happydns.Record{
makeA("example.com.", "1.2.3.4"),
}
corrections := []*happydns.Correction{
{
Id: happydns.Identifier([]byte("add-1")),
Kind: happydns.CorrectionKindAddition,
NewRecords: []happydns.Record{makeA("example.com.", "5.6.7.8")},
},
{
Id: happydns.Identifier([]byte("add-2")),
Kind: happydns.CorrectionKindAddition,
NewRecords: []happydns.Record{makeMX("example.com.", 10, "mail.example.com.")},
},
}
// Only select the first correction.
selectedIDs := []happydns.Identifier{
happydns.Identifier([]byte("add-1")),
}
result := adapter.BuildTargetRecords(providerRecords, corrections, selectedIDs)
if len(result) != 2 {
t.Fatalf("expected 2 records, got %d", len(result))
}
}
func TestBuildTargetRecords_MixedOperations(t *testing.T) {
providerRecords := []happydns.Record{
makeA("example.com.", "1.2.3.4"),
makeA("example.com.", "10.0.0.1"),
}
corrections := []*happydns.Correction{
{
Id: happydns.Identifier([]byte("del-1")),
Kind: happydns.CorrectionKindDeletion,
OldRecords: []happydns.Record{makeA("example.com.", "10.0.0.1")},
},
{
Id: happydns.Identifier([]byte("add-1")),
Kind: happydns.CorrectionKindAddition,
NewRecords: []happydns.Record{makeA("example.com.", "5.6.7.8")},
},
}
selectedIDs := []happydns.Identifier{
happydns.Identifier([]byte("del-1")),
happydns.Identifier([]byte("add-1")),
}
result := adapter.BuildTargetRecords(providerRecords, corrections, selectedIDs)
if len(result) != 2 {
t.Fatalf("expected 2 records, got %d", len(result))
}
// Should have 1.2.3.4 and 5.6.7.8 (10.0.0.1 deleted, 5.6.7.8 added)
found := map[string]bool{}
for _, r := range result {
found[r.String()] = true
}
if !found[makeA("example.com.", "1.2.3.4").String()] {
t.Error("expected record 1.2.3.4 to remain")
}
if !found[makeA("example.com.", "5.6.7.8").String()] {
t.Error("expected record 5.6.7.8 to be added")
}
}
func TestDNSControlDiffByRecord_EnrichedFields(t *testing.T) {
oldRecords := []happydns.Record{
makeA("example.com.", "1.2.3.4"),
}
newRecords := []happydns.Record{
makeA("example.com.", "1.2.3.4"),
makeA("example.com.", "5.6.7.8"),
}
corrections, nbDiffs, err := adapter.DNSControlDiffByRecord(oldRecords, newRecords, "example.com.")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if nbDiffs == 0 {
t.Fatal("expected at least 1 diff")
}
if len(corrections) == 0 {
t.Fatal("expected at least 1 correction")
}
for _, c := range corrections {
if len(c.Id) == 0 {
t.Error("expected correction to have an ID")
}
switch c.Kind {
case happydns.CorrectionKindAddition:
if len(c.NewRecords) == 0 {
t.Error("addition correction should have NewRecords")
}
case happydns.CorrectionKindDeletion:
if len(c.OldRecords) == 0 {
t.Error("deletion correction should have OldRecords")
}
case happydns.CorrectionKindUpdate:
if len(c.OldRecords) == 0 || len(c.NewRecords) == 0 {
t.Error("update correction should have both OldRecords and NewRecords")
}
}
}
}
func TestDNSControlDiffByRecord_NoChanges(t *testing.T) {
records := []happydns.Record{
makeA("example.com.", "1.2.3.4"),
}
corrections, _, err := adapter.DNSControlDiffByRecord(records, records, "example.com.")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(corrections) != 0 {
t.Errorf("expected 0 corrections for identical zones, got %d", len(corrections))
}
}

View file

@ -210,7 +210,8 @@ func (ac *AuthUserController) DeleteAuthUser(c *gin.Context) {
func (ac *AuthUserController) EmailValidationLink(c *gin.Context) {
user := c.MustGet("authuser").(*happydns.UserAuth)
happydns.ApiResponse(c, ac.auService.GenerateValidationLink(user), nil)
link, err := ac.auService.GenerateValidationLink(user)
happydns.ApiResponse(c, link, err)
}
// RecoverUserAcct generates an account recovery link for a user.

View file

@ -0,0 +1,211 @@
// 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
apicontroller "git.happydns.org/happyDomain/internal/api/controller"
"git.happydns.org/happyDomain/model"
)
// CheckerController handles admin-level operations.
// All methods in this controller work with admin-scoped options (nil user/domain/service IDs).
type CheckerController struct {
*apicontroller.BaseCheckerController
}
func NewCheckerController(checkerService happydns.CheckerUsecase) *CheckerController {
return &CheckerController{
BaseCheckerController: apicontroller.NewBaseCheckerController(checkerService),
}
}
// CheckerHandler is a middleware that retrieves a check by name and sets it in the context.
func (uc *CheckerController) CheckerHandler(c *gin.Context) {
cname := c.Param("cname")
checker, err := uc.BaseCheckerController.GetCheckerService().GetChecker(cname)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, happydns.ErrorResponse{Message: "Check not found"})
return
}
c.Set("checker", checker)
c.Next()
}
// CheckerOptionHandler is a middleware that retrieves a specific option and sets it in the context.
func (uc *CheckerController) CheckerOptionHandler(c *gin.Context) {
cname := c.Param("cname")
optname := c.Param("optname")
opts, err := uc.BaseCheckerController.GetCheckerService().GetCheckerOptions(cname, nil, nil, nil)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: err.Error()})
return
}
c.Set("option", (*opts)[optname])
c.Next()
}
// ListCheckers retrieves all available checks.
//
// @Summary List checkers (admin)
// @Schemes
// @Description Returns a list of all available checks with their version information.
// @Tags checks
// @Accept json
// @Produce json
// @Success 200 {object} map[string]happydns.CheckerResponse "Map of checker names to info"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /checks [get]
func (uc *CheckerController) ListCheckers(c *gin.Context) {
uc.BaseCheckerController.ListCheckers(c)
}
// GetCheckerStatus retrieves the status and available options for a check.
//
// @Summary Get check info
// @Schemes
// @Description Retrieves the status information and available options for a specific checker.
// @Tags checks
// @Accept json
// @Produce json
// @Param cname path string true "Checker name"
// @Success 200 {object} happydns.CheckerResponse "Checker status with version info and available options"
// @Failure 404 {object} happydns.ErrorResponse "Checker not found"
// @Router /checks/{cname} [get]
func (uc *CheckerController) GetCheckerStatus(c *gin.Context) {
uc.BaseCheckerController.GetCheckerStatus(c)
}
// GetCheckerOptions retrieves all options for a check.
//
// @Summary Get check options (admin)
// @Schemes
// @Description Retrieves all configuration options for a specific check.
// @Tags checks
// @Accept json
// @Produce json
// @Param cname path string true "Checker name"
// @Success 200 {object} happydns.CheckerOptions "Checker options as key-value pairs"
// @Failure 404 {object} happydns.ErrorResponse "Checker not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /checks/{cname}/options [get]
func (uc *CheckerController) GetCheckerOptions(c *gin.Context) {
cname := c.Param("cname")
// Get admin-level options (nil user/domain/service IDs)
uc.GetCheckerOptionsWithScope(c, cname, nil, nil, nil)
}
// AddCheckerOptions adds or overwrites specific admin-level options for a check.
//
// @Summary Add checker options
// @Schemes
// @Description Adds or overwrites specific configuration options for a checker without affecting other options.
// @Tags checks
// @Accept json
// @Produce json
// @Param cname path string true "Checker name"
// @Param body body happydns.SetCheckerOptionsRequest true "Options to add or overwrite"
// @Success 200 {object} bool "Success status"
// @Failure 400 {object} happydns.ErrorResponse "Invalid request body"
// @Failure 404 {object} happydns.ErrorResponse "Checker not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /checks/{cname}/options [post]
func (uc *CheckerController) AddCheckerOptions(c *gin.Context) {
cname := c.Param("cname")
// Add admin-level options (nil user/domain/service IDs)
uc.AddCheckerOptionsWithScope(c, cname, nil, nil, nil)
}
// ChangeCheckerOptions replaces all options for a checker.
//
// @Summary Replace checker options (admin)
// @Schemes
// @Description Replaces all configuration options for a check with the provided options.
// @Tags checks
// @Accept json
// @Produce json
// @Param cname path string true "Checker name"
// @Param body body happydns.SetCheckerOptionsRequest true "New complete set of options"
// @Success 200 {object} bool "Success status"
// @Failure 400 {object} happydns.ErrorResponse "Invalid request body"
// @Failure 404 {object} happydns.ErrorResponse "Checker not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /checks/{cname}/options [put]
func (uc *CheckerController) ChangeCheckerOptions(c *gin.Context) {
cname := c.Param("cname")
// Replace admin-level options (nil user/domain/service IDs)
uc.ChangeCheckerOptionsWithScope(c, cname, nil, nil, nil)
}
// GetCheckerOption retrieves a specific option value for a checker.
//
// @Summary Get checker option (admin)
// @Schemes
// @Description Retrieves the value of a specific configuration option for a checker.
// @Tags checks
// @Accept json
// @Produce json
// @Param cname path string true "Checker name"
// @Param optname path string true "Option name"
// @Success 200 {object} object "Option value (type varies)"
// @Failure 404 {object} happydns.ErrorResponse "Checker not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /checks/{cname}/options/{optname} [get]
func (uc *CheckerController) GetCheckerOption(c *gin.Context) {
uc.GetCheckerOptionValue(c)
}
// SetCheckerOption sets or updates a specific option value for a checker.
//
// @Summary Set checker option (admin)
// @Schemes
// @Description Sets or updates the value of a specific configuration option for a checker.
// @Tags checks
// @Accept json
// @Produce json
// @Param cname path string true "Checker name"
// @Param optname path string true "Option name"
// @Param body body object true "Option value (type varies by option)"
// @Success 200 {object} bool "Success status"
// @Failure 400 {object} happydns.ErrorResponse "Invalid request body"
// @Failure 404 {object} happydns.ErrorResponse "Checker not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /checks/{cname}/options/{optname} [put]
func (uc *CheckerController) SetCheckerOption(c *gin.Context) {
cname := c.Param("cname")
optname := c.Param("optname")
// Set admin-level option (nil user/domain/service IDs)
uc.SetCheckerOptionWithScope(c, cname, optname, nil, nil, nil)
}

View file

@ -42,7 +42,12 @@ type DomainController struct {
store domain.DomainStorage
}
func NewDomainController(duService happydns.DomainUsecase, remoteZoneImporter happydns.RemoteZoneImporterUsecase, zoneImporter happydns.ZoneImporterUsecase, store domain.DomainStorage) *DomainController {
func NewDomainController(
duService happydns.DomainUsecase,
remoteZoneImporter happydns.RemoteZoneImporterUsecase,
zoneImporter happydns.ZoneImporterUsecase,
store domain.DomainStorage,
) *DomainController {
return &DomainController{
duService,
remoteZoneImporter,
@ -69,7 +74,7 @@ func NewDomainController(duService happydns.DomainUsecase, remoteZoneImporter ha
func (dc *DomainController) ListDomains(c *gin.Context) {
user := middleware.MyUser(c)
if user != nil {
apidc := controller.NewDomainController(dc.domainService, dc.remoteZoneImporter, dc.zoneImporter)
apidc := controller.NewDomainController(dc.domainService, dc.remoteZoneImporter, dc.zoneImporter, nil)
apidc.GetDomains(c)
return
}

View file

@ -0,0 +1,102 @@
// 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/model"
)
// AdminSchedulerController handles admin operations on the test scheduler
type AdminSchedulerController struct {
scheduler happydns.SchedulerUsecase
}
func NewAdminSchedulerController(scheduler happydns.SchedulerUsecase) *AdminSchedulerController {
return &AdminSchedulerController{scheduler: scheduler}
}
// GetSchedulerStatus returns the current scheduler state
//
// @Summary Get scheduler status
// @Description Returns the current state of the test scheduler including worker count, queue size, and upcoming schedules
// @Tags scheduler
// @Produce json
// @Success 200 {object} happydns.SchedulerStatus
// @Router /scheduler [get]
func (ctrl *AdminSchedulerController) GetSchedulerStatus(c *gin.Context) {
c.JSON(http.StatusOK, ctrl.scheduler.GetSchedulerStatus())
}
// EnableScheduler enables the test scheduler at runtime
//
// @Summary Enable scheduler
// @Description Enables the test scheduler at runtime without restarting the server
// @Tags scheduler
// @Success 200 {object} happydns.SchedulerStatus
// @Failure 500 {object} happydns.ErrorResponse
// @Router /scheduler/enable [post]
func (ctrl *AdminSchedulerController) EnableScheduler(c *gin.Context) {
if err := ctrl.scheduler.SetEnabled(true); err != nil {
c.JSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: err.Error()})
return
}
c.JSON(http.StatusOK, ctrl.scheduler.GetSchedulerStatus())
}
// DisableScheduler disables the test scheduler at runtime
//
// @Summary Disable scheduler
// @Description Disables the test scheduler at runtime without restarting the server
// @Tags scheduler
// @Success 200 {object} happydns.SchedulerStatus
// @Failure 500 {object} happydns.ErrorResponse
// @Router /scheduler/disable [post]
func (ctrl *AdminSchedulerController) DisableScheduler(c *gin.Context) {
if err := ctrl.scheduler.SetEnabled(false); err != nil {
c.JSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: err.Error()})
return
}
c.JSON(http.StatusOK, ctrl.scheduler.GetSchedulerStatus())
}
// RescheduleUpcoming randomizes the next run time of all enabled schedules
// within their respective intervals to spread load evenly.
//
// @Summary Reschedule upcoming tests
// @Description Randomizes the next run time of all enabled schedules within their intervals to spread load
// @Tags scheduler
// @Produce json
// @Success 200 {object} map[string]int
// @Failure 500 {object} happydns.ErrorResponse
// @Router /scheduler/reschedule-upcoming [post]
func (ctrl *AdminSchedulerController) RescheduleUpcoming(c *gin.Context) {
n, err := ctrl.scheduler.RescheduleUpcomingChecks()
if err != nil {
c.JSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"rescheduled": n})
}

View file

@ -40,7 +40,12 @@ type ZoneController struct {
store zone.ZoneStorage
}
func NewZoneController(domainService happydns.DomainUsecase, zoneService happydns.ZoneUsecase, zoneCorrectionService happydns.ZoneCorrectionApplierUsecase, store zone.ZoneStorage) *ZoneController {
func NewZoneController(
domainService happydns.DomainUsecase,
zoneService happydns.ZoneUsecase,
zoneCorrectionService happydns.ZoneCorrectionApplierUsecase,
store zone.ZoneStorage,
) *ZoneController {
return &ZoneController{
domainService,
zoneService,
@ -123,7 +128,7 @@ func (zc *ZoneController) DeleteZone(c *gin.Context) {
// @Router /users/{uid}/domains/{domain}/zones/{zoneid} [get]
// @Router /users/{uid}/providers/{pid}/domains/{domain}/zones/{zoneid} [get]
func (zc *ZoneController) GetZone(c *gin.Context) {
apizc := controller.NewZoneController(zc.zoneService, zc.domainService, zc.zoneCorrectionService)
apizc := controller.NewZoneController(zc.zoneService, zc.domainService, zc.zoneCorrectionService, nil)
apizc.GetZone(c)
}

View file

@ -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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
package route
import (
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api-admin/controller"
)
func declareChecksRoutes(router *gin.RouterGroup, dep Dependencies) {
cc := controller.NewCheckerController(dep.Checker)
apiChecksRoutes := router.Group("/checks")
apiChecksRoutes.GET("", cc.ListCheckers)
apiCheckerRoutes := apiChecksRoutes.Group("/:cname")
apiCheckerRoutes.Use(cc.CheckerHandler)
apiCheckerRoutes.GET("", cc.GetCheckerStatus)
//apiCheckerRoutes.POST("", tpc.ChangeCheckerStatus)
apiCheckerRoutes.GET("/options", cc.GetCheckerOptions)
apiCheckerRoutes.POST("/options", cc.AddCheckerOptions)
apiCheckerRoutes.PUT("/options", cc.ChangeCheckerOptions)
apiCheckerOptionsRoutes := apiCheckerRoutes.Group("/options/:optname")
apiCheckerOptionsRoutes.Use(cc.CheckerOptionHandler)
apiCheckerOptionsRoutes.GET("", cc.GetCheckerOption)
apiCheckerOptionsRoutes.PUT("", cc.SetCheckerOption)
}

View file

@ -32,6 +32,8 @@ import (
// Dependencies holds all use cases required to register the admin API routes.
type Dependencies struct {
AuthUser happydns.AuthUserUsecase
Checker happydns.CheckerUsecase
CheckScheduler happydns.SchedulerUsecase
Domain happydns.DomainUsecase
Provider happydns.ProviderUsecase
RemoteZoneImporter happydns.RemoteZoneImporterUsecase
@ -48,7 +50,9 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.Engine, s storage.Storage,
declareBackupRoutes(cfg, apiRoutes, s)
declareDomainRoutes(apiRoutes, dep, s)
declareChecksRoutes(apiRoutes, dep)
declareProviderRoutes(apiRoutes, dep, s)
declareSchedulerRoutes(apiRoutes, dep)
declareSessionsRoutes(cfg, apiRoutes, s)
declareUserAuthsRoutes(apiRoutes, dep, s)
declareUsersRoutes(apiRoutes, dep, s)

View file

@ -0,0 +1,38 @@
// 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
package route
import (
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api-admin/controller"
)
func declareSchedulerRoutes(router *gin.RouterGroup, dep Dependencies) {
ctrl := controller.NewAdminSchedulerController(dep.CheckScheduler)
schedulerRoute := router.Group("/scheduler")
schedulerRoute.GET("", ctrl.GetSchedulerStatus)
schedulerRoute.POST("/enable", ctrl.EnableScheduler)
schedulerRoute.POST("/disable", ctrl.DisableScheduler)
schedulerRoute.POST("/reschedule-upcoming", ctrl.RescheduleUpcoming)
}

View file

@ -29,7 +29,12 @@ import (
"git.happydns.org/happyDomain/internal/storage"
)
func declareZoneServiceRoutes(apiZonesRoutes *gin.RouterGroup, zc *controller.ZoneController, dep Dependencies, store storage.Storage) {
func declareZoneServiceRoutes(
apiZonesRoutes *gin.RouterGroup,
zc *controller.ZoneController,
dep Dependencies,
store storage.Storage,
) {
sc := controller.NewServiceController(
dep.Service,
dep.ZoneService,

View file

@ -0,0 +1,167 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/middleware"
"git.happydns.org/happyDomain/model"
)
// BaseCheckerController contains shared functionality for check controllers.
// It provides common methods that can be used by both admin and user-scoped controllers.
type BaseCheckerController struct {
checkerService happydns.CheckerUsecase
}
func NewBaseCheckerController(checkerService happydns.CheckerUsecase) *BaseCheckerController {
return &BaseCheckerController{
checkerService,
}
}
// GetCheckerService returns the check service for use by derived controllers.
func (bc *BaseCheckerController) GetCheckerService() happydns.CheckerUsecase {
return bc.checkerService
}
// ListCheckers retrieves all available checks.
func (bc *BaseCheckerController) ListCheckers(c *gin.Context) {
checkers, err := bc.checkerService.ListCheckers()
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
res := map[string]happydns.CheckerResponse{}
for name, checker := range *checkers {
_, hasHTML := checker.(happydns.CheckerHTMLReporter)
_, hasMetrics := checker.(happydns.CheckerMetricsReporter)
res[name] = happydns.CheckerResponse{
ID: name,
Name: checker.Name(),
Availability: checker.Availability(),
Options: checker.Options(),
HasHTMLReport: hasHTML,
HasMetrics: hasMetrics,
}
}
happydns.ApiResponse(c, res, nil)
}
// GetCheckerStatus retrieves the status and available options for a check.
func (bc *BaseCheckerController) GetCheckerStatus(c *gin.Context) {
checker := c.MustGet("checker").(happydns.Checker)
_, hasHTML := checker.(happydns.CheckerHTMLReporter)
_, hasMetrics := checker.(happydns.CheckerMetricsReporter)
c.JSON(http.StatusOK, happydns.CheckerResponse{
ID: checker.ID(),
Name: checker.Name(),
Availability: checker.Availability(),
Options: checker.Options(),
HasHTMLReport: hasHTML,
HasMetrics: hasMetrics,
})
}
// getDomainAndServiceIDFromContext extracts optional domainID and serviceID from the gin context.
func getDomainAndServiceIDFromContext(c *gin.Context) (domainID *happydns.Identifier, serviceID *happydns.Identifier) {
if dn, ok := c.Get("domain"); ok {
domainID = &dn.(*happydns.Domain).Id
}
if svcid, ok := c.Get("serviceid"); ok {
tmp := svcid.(happydns.Identifier)
serviceID = &tmp
}
return
}
// GetCheckerOptionsWithScope retrieves all options for a check with the given scope.
func (bc *BaseCheckerController) GetCheckerOptionsWithScope(c *gin.Context, cname string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) {
opts, err := bc.checkerService.GetCheckerOptions(cname, userId, domainId, serviceId)
// For non-basic type, stringify
if opts != nil {
for i, opt := range *opts {
if svc, ok := opt.(*happydns.ServiceMessage); ok {
(*opts)[i] = svc.Type + ": " + svc.Comment
}
}
}
happydns.ApiResponse(c, opts, err)
}
// AddCheckerOptionsWithScope adds or overwrites specific options for a check with the given scope.
func (bc *BaseCheckerController) AddCheckerOptionsWithScope(c *gin.Context, cname string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) {
var req happydns.SetCheckerOptionsRequest
err := c.ShouldBindJSON(&req)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
return
}
err = bc.checkerService.OverwriteSomeCheckerOptions(cname, userId, domainId, serviceId, req.Options)
happydns.ApiResponse(c, true, err)
}
// ChangeCheckerOptionsWithScope replaces all options for a check with the given scope.
func (bc *BaseCheckerController) ChangeCheckerOptionsWithScope(c *gin.Context, cname string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) {
var req happydns.SetCheckerOptionsRequest
err := c.ShouldBindJSON(&req)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
return
}
err = bc.checkerService.SetCheckerOptions(cname, userId, domainId, serviceId, req.Options)
happydns.ApiResponse(c, true, err)
}
// GetCheckerOptionValue retrieves a specific option value from the context.
func (bc *BaseCheckerController) GetCheckerOptionValue(c *gin.Context) {
opt := c.MustGet("option")
happydns.ApiResponse(c, opt, nil)
}
// SetCheckerOptionWithScope sets or updates a specific option value for a check with the given scope.
func (bc *BaseCheckerController) SetCheckerOptionWithScope(c *gin.Context, cname string, optname string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) {
var req any
err := c.ShouldBindJSON(&req)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
return
}
po := happydns.CheckerOptions{}
po[optname] = req
err = bc.checkerService.OverwriteSomeCheckerOptions(cname, userId, domainId, serviceId, po)
happydns.ApiResponse(c, true, err)
}

View file

@ -0,0 +1,247 @@
// 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/model"
)
// CheckerController handles user-scoped check operations for the main API.
// All methods work with options scoped to the authenticated user.
type CheckerController struct {
*BaseCheckerController
}
func NewCheckerController(checkerService happydns.CheckerUsecase) *CheckerController {
return &CheckerController{
BaseCheckerController: NewBaseCheckerController(checkerService),
}
}
// ListCheckers retrieves all available checks.
//
// @Summary List all checks
// @Schemes
// @Description Returns a list of all available checks with their version information.
// @Tags checks
// @Accept json
// @Produce json
// @Success 200 {object} map[string]happydns.CheckerResponse "Map of check names to version info"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /checks [get]
func (uc *CheckerController) ListCheckers(c *gin.Context) {
uc.BaseCheckerController.ListCheckers(c)
}
// GetCheckerStatus retrieves the status and available options for a check.
//
// @Summary Get check status
// @Schemes
// @Description Retrieves the status information and available options for a specific check.
// @Tags checks
// @Accept json
// @Produce json
// @Param cname path string true "Check name"
// @Success 200 {object} happydns.CheckerResponse "Check status with version info and available options"
// @Failure 404 {object} happydns.ErrorResponse "Check not found"
// @Router /checks/{cname} [get]
func (uc *CheckerController) GetCheckerStatus(c *gin.Context) {
uc.BaseCheckerController.GetCheckerStatus(c)
}
// CheckerHandler is a middleware that retrieves a check by name and sets it in the context.
func (uc *CheckerController) CheckerHandler(c *gin.Context) {
cname := c.Param("cname")
check, err := uc.checkerService.GetChecker(cname)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, happydns.ErrorResponse{Message: "Check not found"})
return
}
c.Set("checker", check)
c.Next()
}
// CheckerOptionHandler is a middleware that retrieves a specific check option for the authenticated user and sets it in the context.
func (uc *CheckerController) CheckerOptionHandler(c *gin.Context) {
user := c.MustGet("LoggedUser").(*happydns.User)
cname := c.Param("cname")
optname := c.Param("optname")
opts, err := uc.checkerService.GetCheckerOptions(cname, &user.Id, nil, nil)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: err.Error()})
return
}
c.Set("option", (*opts)[optname])
c.Next()
}
// GetCheckerOptions retrieves all options for a check for the authenticated user.
//
// @Summary Get check options
// @Schemes
// @Description Retrieves all configuration options for a specific check for the authenticated user.
// @Tags checks
// @Accept json
// @Produce json
// @Param cname path string true "Check name"
// @Param domain path string false "Domain identifier"
// @Param zoneid path string false "Zone identifier"
// @Param subdomain path string false "Subdomain"
// @Param serviceid path string false "Service identifier"
// @Success 200 {object} happydns.CheckerOptions "Check options as key-value pairs"
// @Failure 404 {object} happydns.ErrorResponse "Check not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /checks/{cname}/options [get]
// @Router /domains/{domain}/checks/{cname}/options [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname}/options [get]
func (uc *CheckerController) GetCheckerOptions(c *gin.Context) {
user := c.MustGet("LoggedUser").(*happydns.User)
cname := c.Param("cname")
domainID, serviceID := getDomainAndServiceIDFromContext(c)
uc.GetCheckerOptionsWithScope(c, cname, &user.Id, domainID, serviceID)
}
// AddCheckerOptions adds or overwrites specific options for a check for the authenticated user.
//
// @Summary Add check options
// @Schemes
// @Description Adds or overwrites specific configuration options for a check for the authenticated user without affecting other options.
// @Tags checks
// @Accept json
// @Produce json
// @Param cname path string true "Check name"
// @Param domain path string false "Domain identifier"
// @Param zoneid path string false "Zone identifier"
// @Param subdomain path string false "Subdomain"
// @Param serviceid path string false "Service identifier"
// @Param body body happydns.SetCheckerOptionsRequest true "Options to add or overwrite"
// @Success 200 {object} bool "Success status"
// @Failure 400 {object} happydns.ErrorResponse "Invalid request body"
// @Failure 404 {object} happydns.ErrorResponse "Check not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /checks/{cname}/options [post]
// @Router /domains/{domain}/checks/{cname}/options [post]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname}/options [post]
func (uc *CheckerController) AddCheckerOptions(c *gin.Context) {
user := c.MustGet("LoggedUser").(*happydns.User)
cname := c.Param("cname")
domainID, serviceID := getDomainAndServiceIDFromContext(c)
uc.AddCheckerOptionsWithScope(c, cname, &user.Id, domainID, serviceID)
}
// ChangeCheckerOptions replaces all options for a check for the authenticated user.
//
// @Summary Replace check options
// @Schemes
// @Description Replaces all configuration options for a check for the authenticated user with the provided options.
// @Tags checks
// @Accept json
// @Produce json
// @Param cname path string true "Checker name"
// @Param domain path string false "Domain identifier"
// @Param zoneid path string false "Zone identifier"
// @Param subdomain path string false "Subdomain"
// @Param serviceid path string false "Service identifier"
// @Param body body happydns.SetCheckerOptionsRequest true "New complete set of options"
// @Success 200 {object} bool "Success status"
// @Failure 400 {object} happydns.ErrorResponse "Invalid request body"
// @Failure 404 {object} happydns.ErrorResponse "Checker not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /checks/{cname}/options [put]
// @Router /domains/{domain}/checks/{cname}/options [put]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname}/options [put]
func (uc *CheckerController) ChangeCheckerOptions(c *gin.Context) {
user := c.MustGet("LoggedUser").(*happydns.User)
cname := c.Param("cname")
domainID, serviceID := getDomainAndServiceIDFromContext(c)
uc.ChangeCheckerOptionsWithScope(c, cname, &user.Id, domainID, serviceID)
}
// GetCheckerOption retrieves a specific option value for a check for the authenticated user.
//
// @Summary Get check option
// @Schemes
// @Description Retrieves the value of a specific configuration option for a check for the authenticated user.
// @Tags checks
// @Accept json
// @Produce json
// @Param cname path string true "Check name"
// @Param optname path string true "Option name"
// @Param domain path string false "Domain identifier"
// @Param zoneid path string false "Zone identifier"
// @Param subdomain path string false "Subdomain"
// @Param serviceid path string false "Service identifier"
// @Success 200 {object} object "Option value (type varies)"
// @Failure 404 {object} happydns.ErrorResponse "Check not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /checks/{cname}/options/{optname} [get]
// @Router /domains/{domain}/checks/{cname}/options/{optname} [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname}/options/{optname} [get]
func (uc *CheckerController) GetCheckerOption(c *gin.Context) {
uc.GetCheckerOptionValue(c)
}
// SetCheckerOption sets or updates a specific option value for a check for the authenticated user.
//
// @Summary Set check option
// @Schemes
// @Description Sets or updates the value of a specific configuration option for a check for the authenticated user.
// @Tags checks
// @Accept json
// @Produce json
// @Param cname path string true "Check name"
// @Param optname path string true "Option name"
// @Param domain path string false "Domain identifier"
// @Param zoneid path string false "Zone identifier"
// @Param subdomain path string false "Subdomain"
// @Param serviceid path string false "Service identifier"
// @Param body body object true "Option value (type varies by option)"
// @Success 200 {object} bool "Success status"
// @Failure 400 {object} happydns.ErrorResponse "Invalid request body"
// @Failure 404 {object} happydns.ErrorResponse "Check not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /checks/{cname}/options/{optname} [put]
// @Router /domains/{domain}/checks/{cname}/options/{optname} [put]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname}/options/{optname} [put]
func (uc *CheckerController) SetCheckerOption(c *gin.Context) {
user := c.MustGet("LoggedUser").(*happydns.User)
cname := c.Param("cname")
optname := c.Param("optname")
uc.SetCheckerOptionWithScope(c, cname, optname, &user.Id, nil, nil)
}

View file

@ -0,0 +1,549 @@
// 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
package controller
import (
"encoding/json"
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/checks"
"git.happydns.org/happyDomain/internal/api/middleware"
"git.happydns.org/happyDomain/model"
)
// CheckResultController handles check result operations
type CheckResultController struct {
scope happydns.CheckScopeType
checkerUC happydns.CheckerUsecase
checkResultUC happydns.CheckResultUsecase
checkScheduler happydns.SchedulerUsecase
}
func NewCheckResultController(
scope happydns.CheckScopeType,
checkerUC happydns.CheckerUsecase,
checkResultUC happydns.CheckResultUsecase,
checkScheduler happydns.SchedulerUsecase,
) *CheckResultController {
return &CheckResultController{
scope: scope,
checkerUC: checkerUC,
checkResultUC: checkResultUC,
checkScheduler: checkScheduler,
}
}
// getTargetFromContext extracts the target ID from context based on scope
func (tc *CheckResultController) getTargetFromContext(c *gin.Context) (happydns.Identifier, error) {
switch tc.scope {
case happydns.CheckScopeUser:
user := c.MustGet("user").(*happydns.User)
return user.Id, nil
case happydns.CheckScopeDomain:
domain := c.MustGet("domain").(*happydns.Domain)
return domain.Id, nil
case happydns.CheckScopeService:
// Services are stored by ID in context
serviceID := c.MustGet("serviceid").(happydns.Identifier)
return serviceID, nil
default:
return happydns.Identifier{}, fmt.Errorf("unsupported scope")
}
}
// ListAvailableChecks lists all available check plugins for the target scope
//
// @Summary List available checks
// @Description Retrieves all available check plugins for the target scope with their last execution status if enabled
// @Tags checks
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param zoneid path string false "Zone identifier"
// @Param subdomain path string false "Subdomain"
// @Param serviceid path string false "Service identifier"
// @Success 200 {array} object "List of available checks"
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checks [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks [get]
func (tc *CheckResultController) ListAvailableChecks(c *gin.Context) {
domain := c.MustGet("domain").(*happydns.Domain)
var service *happydns.Service
if svc, ok := c.Get("service"); ok {
service = svc.(*happydns.Service)
}
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
checks, err := tc.checkResultUC.ListCheckerStatuses(tc.scope, targetID, domain, service)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, checks)
}
// ListLatestCheckResults retrieves the lacheck check results for a specific plugin
//
// @Summary Get lacheck check results
// @Description Retrieves the 5 most recent check results for a specific plugin and target
// @Tags checks
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param zoneid path string false "Zone identifier"
// @Param subdomain path string false "Subdomain"
// @Param serviceid path string false "Service identifier"
// @Param cname path string true "Check plugin name"
// @Success 200 {array} happydns.CheckResult
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checks/{cname} [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname} [get]
func (tc *CheckResultController) ListLatestCheckResults(c *gin.Context) {
checkName := c.Param("cname")
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
results, err := tc.checkResultUC.ListCheckResultsByTarget(checkName, tc.scope, targetID, 5)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, results)
}
// TriggerCheck triggers an on-demand check execution
//
// @Summary Trigger check execution
// @Description Triggers an immediate check execution and returns the execution ID
// @Tags checks
// @Accept json
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param zoneid path string false "Zone identifier"
// @Param subdomain path string false "Subdomain"
// @Param serviceid path string false "Service identifier"
// @Param cname path string true "Check plugin name"
// @Param body body object false "Optional: Plugin options"
// @Success 202 {object} object{execution_id=string}
// @Failure 400 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checks/{cname} [post]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname} [post]
func (tc *CheckResultController) TriggerCheck(c *gin.Context) {
user := middleware.MyUser(c)
checkName := c.Param("cname")
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
// Parse run options
var options happydns.SetCheckerOptionsRequest
if err = c.ShouldBindJSON(&options); err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
return
}
// Trigger the test via scheduler (returns error if scheduler is disabled)
executionID, err := tc.checkScheduler.TriggerOnDemandCheck(checkName, tc.scope, targetID, user.Id, options.Options)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusAccepted, gin.H{"execution_id": executionID.String()})
}
// GetCheckExecutionStatus retrieves the status of a check execution
//
// @Summary Get check execution status
// @Description Retrieves the current status of a check execution
// @Tags checks
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param zoneid path string false "Zone identifier"
// @Param subdomain path string false "Subdomain"
// @Param serviceid path string false "Service identifier"
// @Param cname path string true "Check plugin name"
// @Param execution_id path string true "Execution ID"
// @Success 200 {object} happydns.CheckExecution
// @Failure 404 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checks/{cname}/executions/{execution_id} [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname}/executions/{execution_id} [get]
func (tc *CheckResultController) GetCheckExecutionStatus(c *gin.Context) {
executionIDStr := c.Param("execution_id")
executionID, err := happydns.NewIdentifierFromString(executionIDStr)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid execution ID"))
return
}
execution, err := tc.checkResultUC.GetCheckExecution(executionID)
if err != nil {
middleware.ErrorResponse(c, http.StatusNotFound, err)
return
}
c.JSON(http.StatusOK, execution)
}
// ListCheckResults lists all results for a check plugin
//
// @Summary List check results
// @Description Lists all check results for a specific check plugin and target
// @Tags checks
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param zoneid path string false "Zone identifier"
// @Param subdomain path string false "Subdomain"
// @Param serviceid path string false "Service identifier"
// @Param cname path string true "Check plugin name"
// @Param limit query int false "Maximum number of results to return (default: 10)"
// @Success 200 {array} happydns.CheckResult
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checks/{cname}/results [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname}/results [get]
func (tc *CheckResultController) ListCheckResults(c *gin.Context) {
checkName := c.Param("cname")
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
// Parse limit parameter
limit := 10
if limitStr := c.Query("limit"); limitStr != "" {
fmt.Sscanf(limitStr, "%d", &limit)
}
results, err := tc.checkResultUC.ListCheckResultsByTarget(checkName, tc.scope, targetID, limit)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, results)
}
// DropCheckResults deletes all results for a check plugin
//
// @Summary Delete all check results
// @Description Deletes all check results for a specific check plugin and target
// @Tags checks
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param zoneid path string false "Zone identifier"
// @Param subdomain path string false "Subdomain"
// @Param serviceid path string false "Service identifier"
// @Param cname path string true "Check plugin name"
// @Success 204 "No Content"
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checks/{cname}/results [delete]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname}/results [delete]
func (tc *CheckResultController) DropCheckResults(c *gin.Context) {
checkName := c.Param("cname")
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
err = tc.checkResultUC.DeleteAllCheckResults(checkName, tc.scope, targetID)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.Status(http.StatusNoContent)
}
// GetCheckPluginResult retrieves a specific check result
//
// @Summary Get check result
// @Description Retrieves a specific check result by ID
// @Tags checks
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param zoneid path string false "Zone identifier"
// @Param subdomain path string false "Subdomain"
// @Param serviceid path string false "Service identifier"
// @Param cname path string true "Check plugin name"
// @Param result_id path string true "Result ID"
// @Success 200 {object} happydns.CheckResult
// @Failure 404 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checks/{cname}/results/{result_id} [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname}/results/{result_id} [get]
func (tc *CheckResultController) GetCheckResult(c *gin.Context) {
checkName := c.Param("cname")
resultIDStr := c.Param("result_id")
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
resultID, err := happydns.NewIdentifierFromString(resultIDStr)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid result ID"))
return
}
result, err := tc.checkResultUC.GetCheckResult(checkName, tc.scope, targetID, resultID)
if err != nil {
middleware.ErrorResponse(c, http.StatusNotFound, err)
return
}
c.JSON(http.StatusOK, result)
}
// GetCheckResultHTMLReport returns the HTML report for a specific check result
//
// @Summary Get check result HTML report
// @Description Returns the full HTML document generated from the check result's report data. Only available for checkers that implement HTML reporting.
// @Tags checks
// @Produce html
// @Param domain path string true "Domain identifier"
// @Param zoneid path string false "Zone identifier"
// @Param subdomain path string false "Subdomain"
// @Param serviceid path string false "Service identifier"
// @Param cname path string true "Check plugin name"
// @Param result_id path string true "Result ID"
// @Success 200 {string} string "HTML document"
// @Failure 404 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checks/{cname}/results/{result_id}/report [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname}/results/{result_id}/report [get]
func (tc *CheckResultController) GetCheckResultHTMLReport(c *gin.Context) {
checkName := c.Param("cname")
resultIDStr := c.Param("result_id")
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
resultID, err := happydns.NewIdentifierFromString(resultIDStr)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid result ID"))
return
}
result, err := tc.checkResultUC.GetCheckResult(checkName, tc.scope, targetID, resultID)
if err != nil {
middleware.ErrorResponse(c, http.StatusNotFound, err)
return
}
checker, err := tc.checkerUC.GetChecker(checkName)
if err != nil {
middleware.ErrorResponse(c, http.StatusNotFound, err)
return
}
raw, err := json.Marshal(result.Report)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
htmlContent, supported, err := checks.GetHTMLReport(checker, json.RawMessage(raw))
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
if !supported {
middleware.ErrorResponse(c, http.StatusNotFound, fmt.Errorf("checker %q does not support HTML reports", checkName))
return
}
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(htmlContent))
}
// GetCheckResultMetrics returns time-series metrics extracted from check results
//
// @Summary Get check result metrics
// @Description Returns time-series metrics suitable for charting, extracted from recent check results. Only available for checkers that implement metrics reporting.
// @Tags checks
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param zoneid path string false "Zone identifier"
// @Param subdomain path string false "Subdomain"
// @Param serviceid path string false "Service identifier"
// @Param cname path string true "Check plugin name"
// @Param limit query int false "Maximum number of results to extract metrics from (default: 100)"
// @Success 200 {object} happydns.MetricsReport
// @Failure 404 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checks/{cname}/metrics [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname}/metrics [get]
func (tc *CheckResultController) GetCheckResultMetrics(c *gin.Context) {
checkName := c.Param("cname")
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
limit := 100
if limitStr := c.Query("limit"); limitStr != "" {
fmt.Sscanf(limitStr, "%d", &limit)
}
checker, err := tc.checkerUC.GetChecker(checkName)
if err != nil {
middleware.ErrorResponse(c, http.StatusNotFound, err)
return
}
results, err := tc.checkResultUC.ListCheckResultsByTarget(checkName, tc.scope, targetID, limit)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
report, supported, err := checks.GetMetrics(checker, results)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
if !supported {
middleware.ErrorResponse(c, http.StatusNotFound, fmt.Errorf("checker %q does not support metrics", checkName))
return
}
c.JSON(http.StatusOK, report)
}
// GetSingleCheckResultMetrics returns metrics extracted from a single check result
//
// @Summary Get single check result metrics
// @Description Returns metrics extracted from a single check result. Only available for checkers that implement metrics reporting.
// @Tags checks
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param zoneid path string false "Zone identifier"
// @Param subdomain path string false "Subdomain"
// @Param serviceid path string false "Service identifier"
// @Param cname path string true "Check plugin name"
// @Param result_id path string true "Result ID"
// @Success 200 {object} happydns.MetricsReport
// @Failure 404 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checks/{cname}/results/{result_id}/metrics [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname}/results/{result_id}/metrics [get]
func (tc *CheckResultController) GetSingleCheckResultMetrics(c *gin.Context) {
checkName := c.Param("cname")
resultIDStr := c.Param("result_id")
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
resultID, err := happydns.NewIdentifierFromString(resultIDStr)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid result ID"))
return
}
result, err := tc.checkResultUC.GetCheckResult(checkName, tc.scope, targetID, resultID)
if err != nil {
middleware.ErrorResponse(c, http.StatusNotFound, err)
return
}
checker, err := tc.checkerUC.GetChecker(checkName)
if err != nil {
middleware.ErrorResponse(c, http.StatusNotFound, err)
return
}
report, supported, err := checks.GetMetrics(checker, []*happydns.CheckResult{result})
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
if !supported {
middleware.ErrorResponse(c, http.StatusNotFound, fmt.Errorf("checker %q does not support metrics", checkName))
return
}
c.JSON(http.StatusOK, report)
}
// DropCheckResult deletes a specific check result
//
// @Summary Delete check result
// @Description Deletes a specific check result by ID
// @Tags checks
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param zoneid path string false "Zone identifier"
// @Param subdomain path string false "Subdomain"
// @Param serviceid path string false "Service identifier"
// @Param cname path string true "Check plugin name"
// @Param result_id path string true "Result ID"
// @Success 204 "No Content"
// @Failure 404 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checks/{cname}/results/{result_id} [delete]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname}/results/{result_id} [delete]
func (tc *CheckResultController) DropCheckResult(c *gin.Context) {
checkName := c.Param("cname")
resultIDStr := c.Param("result_id")
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
resultID, err := happydns.NewIdentifierFromString(resultIDStr)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid result ID"))
return
}
err = tc.checkResultUC.DeleteCheckResult(checkName, tc.scope, targetID, resultID)
if err != nil {
middleware.ErrorResponse(c, http.StatusNotFound, err)
return
}
c.Status(http.StatusNoContent)
}

View file

@ -37,13 +37,15 @@ type DomainController struct {
domainService happydns.DomainUsecase
remoteZoneImporter happydns.RemoteZoneImporterUsecase
zoneImporter happydns.ZoneImporterUsecase
checkResultUC happydns.CheckResultUsecase
}
func NewDomainController(domainService happydns.DomainUsecase, remoteZoneImporter happydns.RemoteZoneImporterUsecase, zoneImporter happydns.ZoneImporterUsecase) *DomainController {
func NewDomainController(domainService happydns.DomainUsecase, remoteZoneImporter happydns.RemoteZoneImporterUsecase, zoneImporter happydns.ZoneImporterUsecase, checkResultUC happydns.CheckResultUsecase) *DomainController {
return &DomainController{
domainService: domainService,
remoteZoneImporter: remoteZoneImporter,
zoneImporter: zoneImporter,
checkResultUC: checkResultUC,
}
}
@ -56,7 +58,7 @@ func NewDomainController(domainService happydns.DomainUsecase, remoteZoneImporte
// @Accept json
// @Produce json
// @Security securitydefinitions.basic
// @Success 200 {array} happydns.Domain
// @Success 200 {array} happydns.DomainWithCheckStatus
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
// @Failure 404 {object} happydns.ErrorResponse "Unable to retrieve user's domains"
// @Router /domains [get]
@ -73,7 +75,25 @@ func (dc *DomainController) GetDomains(c *gin.Context) {
return
}
c.JSON(http.StatusOK, domains)
var statusByDomain map[string]*happydns.CheckResultStatus
if dc.checkResultUC != nil {
var err error
statusByDomain, err = dc.checkResultUC.GetWorstCheckStatusByUser(happydns.CheckScopeDomain, user.Id)
if err != nil {
log.Printf("GetWorstCheckStatusByUser: %s", err.Error())
}
}
result := make([]*happydns.DomainWithCheckStatus, 0, len(domains))
for _, d := range domains {
entry := &happydns.DomainWithCheckStatus{Domain: d}
if statusByDomain != nil {
entry.LastCheckStatus = statusByDomain[d.Id.String()]
}
result = append(result, entry)
}
c.JSON(http.StatusOK, result)
}
// AddDomain appends a new domain to those managed.
@ -234,7 +254,7 @@ func (dc *DomainController) RetrieveZone(c *gin.Context) {
}
domain := c.MustGet("domain").(*happydns.Domain)
zone, err := dc.remoteZoneImporter.Import(user, domain)
zone, err := dc.remoteZoneImporter.Import(c.Request.Context(), user, domain)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return

View file

@ -34,16 +34,18 @@ import (
)
type ServiceController struct {
suService happydns.ServiceUsecase
duService happydns.ZoneServiceUsecase
zuService happydns.ZoneUsecase
checkResultUC happydns.CheckResultUsecase
suService happydns.ServiceUsecase
duService happydns.ZoneServiceUsecase
zuService happydns.ZoneUsecase
}
func NewServiceController(duService happydns.ZoneServiceUsecase, suService happydns.ServiceUsecase, zuService happydns.ZoneUsecase) *ServiceController {
func NewServiceController(duService happydns.ZoneServiceUsecase, suService happydns.ServiceUsecase, zuService happydns.ZoneUsecase, checkResultUC happydns.CheckResultUsecase) *ServiceController {
return &ServiceController{
duService: duService,
suService: suService,
zuService: zuService,
checkResultUC: checkResultUC,
duService: duService,
suService: suService,
zuService: zuService,
}
}
@ -106,18 +108,26 @@ func (sc *ServiceController) AddZoneService(c *gin.Context) {
// @Param zoneId path string true "Zone identifier"
// @Param subdomain path string true "Part of the subdomain considered for the service (@ for the root of the zone ; subdomain is relative to the root, do not include it)"
// @Param serviceId path string true "Service identifier"
// @Success 200 {object} happydns.Service
// @Success 200 {object} happydns.ServiceWithCheckStatus
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
// @Failure 404 {object} happydns.ErrorResponse "Domain or Zone not found"
// @Router /domains/{domainId}/zone/{zoneId}/{subdomain}/services/{serviceId} [get]
func (sc *ServiceController) GetZoneService(c *gin.Context) {
zone := c.MustGet("zone").(*happydns.Zone)
serviceid := c.MustGet("serviceid").(happydns.Identifier)
subdomain := c.MustGet("subdomain").(happydns.Subdomain)
user := middleware.MyUser(c)
svc := c.MustGet("service").(*happydns.Service)
_, svc := zone.FindSubdomainService(subdomain, serviceid)
result := &happydns.ServiceWithCheckStatus{Service: svc}
c.JSON(http.StatusOK, svc)
if sc.checkResultUC != nil && user != nil {
status, err := sc.checkResultUC.GetWorstCheckStatus(happydns.CheckScopeService, svc.Id, user.Id)
if err != nil {
log.Printf("GetWorstCheckStatus: %s", err.Error())
} else {
result.LastCheckStatus = status
}
}
c.JSON(http.StatusOK, result)
}
// UpdateZoneService adds or updates a service inside the given Zone.

View file

@ -22,22 +22,28 @@
package controller
import (
"fmt"
"log"
"net/http"
"reflect"
"strconv"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/middleware"
serviceUC "git.happydns.org/happyDomain/internal/usecase/service"
"git.happydns.org/happyDomain/model"
)
type ServiceSpecsController struct {
sSpecsServices happydns.ServiceSpecsUsecase
sSpecsServices happydns.ServiceSpecsUsecase
listRecordsService *serviceUC.ListRecordsUsecase
}
func NewServiceSpecsController(sSpecsServices happydns.ServiceSpecsUsecase) *ServiceSpecsController {
return &ServiceSpecsController{
sSpecsServices: sSpecsServices,
sSpecsServices: sSpecsServices,
listRecordsService: serviceUC.NewListRecordsUsecase(),
}
}
@ -79,7 +85,7 @@ func (ssc *ServiceSpecsController) GetServiceSpecIcon(c *gin.Context) {
c.Data(http.StatusOK, "image/png", cnt)
}
// getServiceSpec returns a description of the expected fields.
// GetServiceSpec returns a description of the expected fields.
//
// @Summary Get the service expected fields.
// @Schemes
@ -127,3 +133,54 @@ func (ssc *ServiceSpecsController) InitializeServiceSpec(c *gin.Context) {
c.JSON(http.StatusOK, initialized)
}
// GenerateRecords returns the DNS records that the service would generate.
//
// @Summary Generate DNS records for a service.
// @Schemes
// @Description Return the DNS records that the given service configuration would generate.
// @Tags service_specs
// @Accept json
// @Produce json
// @Param serviceType path string true "The service's type"
// @Param domain query string true "The domain to use to generate the records"
// @Param ttl query int false "The TTL used by the generated records"
// @Success 200 {array} happydns.Record
// @Failure 400 {object} happydns.ErrorResponse "Invalid request body"
// @Failure 404 {object} happydns.ErrorResponse "Service type does not exist"
// @Failure 500 {object} happydns.ErrorResponse "Internal error"
// @Router /service_specs/{serviceType}/records [post]
func (ssc *ServiceSpecsController) GenerateRecords(c *gin.Context) {
svctype := c.MustGet("servicetype").(reflect.Type)
domain := c.Query("domain")
ttl, _ := strconv.Atoi(c.Query("ttl"))
if ttl == 0 {
ttl = 3600
}
svc, err := ssc.sSpecsServices.InitializeService(svctype)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
err = c.ShouldBindJSON(&svc)
if err != nil {
log.Printf("%s sends invalid domain JSON: %s", c.ClientIP(), err.Error())
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": fmt.Sprintf("Something is wrong in received data: %s", err.Error())})
return
}
records, err := ssc.listRecordsService.List(&happydns.Service{
ServiceMeta: happydns.ServiceMeta{
Domain: domain,
},
Service: svc.(happydns.ServiceBody),
}, domain, uint32(ttl))
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, records)
}

View file

@ -0,0 +1,231 @@
// 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
package controller
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/middleware"
"git.happydns.org/happyDomain/model"
)
// CheckerScheduleController handles test schedule operations
type CheckerScheduleController struct {
testScheduleUC happydns.CheckerScheduleUsecase
}
func NewCheckerScheduleController(testScheduleUC happydns.CheckerScheduleUsecase) *CheckerScheduleController {
return &CheckerScheduleController{
testScheduleUC: testScheduleUC,
}
}
// ListCheckerSchedules retrieves schedules for the authenticated user
//
// @Summary List test schedules
// @Description Retrieves test schedules for the authenticated user with optional pagination
// @Tags test-schedules
// @Produce json
// @Param limit query int false "Maximum number of schedules to return (0 = all)"
// @Param offset query int false "Number of schedules to skip (default: 0)"
// @Success 200 {array} happydns.CheckerSchedule
// @Failure 500 {object} happydns.ErrorResponse
// @Router /plugins/tests/schedules [get]
func (tc *CheckerScheduleController) ListCheckerSchedules(c *gin.Context) {
user := middleware.MyUser(c)
schedules, err := tc.testScheduleUC.ListUserSchedules(user.Id)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
// Apply pagination
limit := 0
offset := 0
fmt.Sscanf(c.Query("limit"), "%d", &limit)
fmt.Sscanf(c.Query("offset"), "%d", &offset)
if offset > len(schedules) {
offset = len(schedules)
}
schedules = schedules[offset:]
if limit > 0 && len(schedules) > limit {
schedules = schedules[:limit]
}
c.JSON(http.StatusOK, schedules)
}
// CreateCheckerSchedule creates a new test schedule
//
// @Summary Create test schedule
// @Description Creates a new test schedule for the authenticated user
// @Tags test-schedules
// @Accept json
// @Produce json
// @Param body body happydns.CheckerSchedule true "Check schedule to create"
// @Success 201 {object} happydns.CheckerSchedule
// @Failure 400 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /plugins/tests/schedules [post]
func (tc *CheckerScheduleController) CreateCheckerSchedule(c *gin.Context) {
user := middleware.MyUser(c)
var schedule happydns.CheckerSchedule
if err := c.ShouldBindJSON(&schedule); err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
return
}
// Set user ID
schedule.OwnerId = user.Id
if err := tc.testScheduleUC.CreateSchedule(&schedule); err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusCreated, schedule)
}
// GetCheckerSchedule retrieves a specific schedule
//
// @Summary Get test schedule
// @Description Retrieves a specific test schedule by ID
// @Tags test-schedules
// @Produce json
// @Param schedule_id path string true "Schedule ID"
// @Success 200 {object} happydns.CheckerSchedule
// @Failure 404 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /plugins/tests/schedules/{schedule_id} [get]
func (tc *CheckerScheduleController) GetCheckerSchedule(c *gin.Context) {
user := middleware.MyUser(c)
scheduleIdStr := c.Param("schedule_id")
scheduleId, err := happydns.NewIdentifierFromString(scheduleIdStr)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid schedule ID"))
return
}
// Verify ownership
if err := tc.testScheduleUC.ValidateScheduleOwnership(scheduleId, user.Id); err != nil {
middleware.ErrorResponse(c, http.StatusForbidden, err)
return
}
schedule, err := tc.testScheduleUC.GetSchedule(scheduleId)
if err != nil {
middleware.ErrorResponse(c, http.StatusNotFound, err)
return
}
c.JSON(http.StatusOK, schedule)
}
// UpdateCheckerSchedule updates an existing schedule
//
// @Summary Update test schedule
// @Description Updates an existing test schedule
// @Tags test-schedules
// @Accept json
// @Produce json
// @Param schedule_id path string true "Schedule ID"
// @Param body body happydns.CheckerSchedule true "Updated schedule"
// @Success 200 {object} happydns.CheckerSchedule
// @Failure 400 {object} happydns.ErrorResponse
// @Failure 404 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /plugins/tests/schedules/{schedule_id} [put]
func (tc *CheckerScheduleController) UpdateCheckerSchedule(c *gin.Context) {
user := middleware.MyUser(c)
scheduleIdStr := c.Param("schedule_id")
scheduleId, err := happydns.NewIdentifierFromString(scheduleIdStr)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid schedule ID"))
return
}
// Verify ownership
if err := tc.testScheduleUC.ValidateScheduleOwnership(scheduleId, user.Id); err != nil {
middleware.ErrorResponse(c, http.StatusForbidden, err)
return
}
var schedule happydns.CheckerSchedule
if err := c.ShouldBindJSON(&schedule); err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
return
}
// Ensure ID matches
schedule.Id = scheduleId
schedule.OwnerId = user.Id
if err := tc.testScheduleUC.UpdateSchedule(&schedule); err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, schedule)
}
// DeleteCheckerSchedule deletes a schedule
//
// @Summary Delete test schedule
// @Description Deletes a test schedule
// @Tags test-schedules
// @Produce json
// @Param schedule_id path string true "Schedule ID"
// @Success 204 "No Content"
// @Failure 404 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /plugins/tests/schedules/{schedule_id} [delete]
func (tc *CheckerScheduleController) DeleteCheckerSchedule(c *gin.Context) {
user := middleware.MyUser(c)
scheduleIdStr := c.Param("schedule_id")
scheduleId, err := happydns.NewIdentifierFromString(scheduleIdStr)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid schedule ID"))
return
}
// Verify ownership
if err := tc.testScheduleUC.ValidateScheduleOwnership(scheduleId, user.Id); err != nil {
middleware.ErrorResponse(c, http.StatusForbidden, err)
return
}
if err := tc.testScheduleUC.DeleteSchedule(scheduleId); err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.Status(http.StatusNoContent)
}

View file

@ -52,7 +52,7 @@ func NewRegistrationController(auService happydns.AuthUserUsecase, captchaVerifi
// @Accept json
// @Produce json
// @Param body body happydns.UserRegistration true "Account information"
// @Success 200 {object} happydns.User "The created user"
// @Success 204
// @Failure 400 {object} happydns.ErrorResponse "Invalid input"
// @Failure 500 {object} happydns.ErrorResponse
// @Router /users [post]
@ -85,7 +85,10 @@ func (rc *RegistrationController) RegisterNewUser(c *gin.Context) {
return
}
log.Printf("%s: registers new user: %s", c.ClientIP(), user.Email)
if user != nil {
log.Printf("%s: registers new user: %s", c.ClientIP(), user.Email)
}
c.JSON(http.StatusOK, user)
// Always return the same response to prevent user enumeration.
c.Status(http.StatusNoContent)
}

View file

@ -35,13 +35,15 @@ import (
)
type ZoneController struct {
checkResultUC happydns.CheckResultUsecase
domainService happydns.DomainUsecase
zoneCorrectionService happydns.ZoneCorrectionApplierUsecase
zoneService happydns.ZoneUsecase
}
func NewZoneController(zoneService happydns.ZoneUsecase, domainService happydns.DomainUsecase, zoneCorrectionService happydns.ZoneCorrectionApplierUsecase) *ZoneController {
func NewZoneController(zoneService happydns.ZoneUsecase, domainService happydns.DomainUsecase, zoneCorrectionService happydns.ZoneCorrectionApplierUsecase, checkResultUC happydns.CheckResultUsecase) *ZoneController {
return &ZoneController{
checkResultUC: checkResultUC,
domainService: domainService,
zoneCorrectionService: zoneCorrectionService,
zoneService: zoneService,
@ -59,14 +61,37 @@ func NewZoneController(zoneService happydns.ZoneUsecase, domainService happydns.
// @Security securitydefinitions.basic
// @Param domainId path string true "Domain identifier"
// @Param zoneId path string true "Zone identifier"
// @Success 200 {object} happydns.Zone
// @Success 200 {object} happydns.ZoneWithServicesCheckStatus
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
// @Failure 404 {object} happydns.ErrorResponse "Domain or Zone not found"
// @Router /domains/{domainId}/zone/{zoneId} [get]
func (zc *ZoneController) GetZone(c *gin.Context) {
user := middleware.MyUser(c)
zone := c.MustGet("zone").(*happydns.Zone)
c.JSON(http.StatusOK, zone)
result := &happydns.ZoneWithServicesCheckStatus{Zone: zone}
if zc.checkResultUC != nil && user != nil {
statusByService, err := zc.checkResultUC.GetWorstCheckStatusByUser(happydns.CheckScopeService, user.Id)
if err != nil {
log.Printf("GetWorstCheckStatusByUser: %s", err.Error())
} else if statusByService != nil {
result.ServicesCheckStatus = make(map[string]*happydns.CheckResultStatus)
for subdomain := range zone.Services {
for _, svc := range zone.Services[subdomain] {
key := svc.Id.String()
if status, ok := statusByService[key]; ok {
result.ServicesCheckStatus[key] = status
}
}
}
if len(result.ServicesCheckStatus) == 0 {
result.ServicesCheckStatus = nil
}
}
}
c.JSON(http.StatusOK, result)
}
// GetZoneSubdomain returns the services associated with a given subdomain.
@ -107,7 +132,7 @@ func (zc *ZoneController) DiffZonesHandler(c *gin.Context) {
var corrections []*happydns.Correction
if c.Param("oldzoneid") == "@" {
var err error
corrections, nbDiffs, err = zc.zoneCorrectionService.List(user, domain, newzone)
corrections, nbDiffs, err = zc.zoneCorrectionService.List(c.Request.Context(), user, domain, newzone)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
@ -214,7 +239,7 @@ func (zc *ZoneController) ApplyZoneCorrections(c *gin.Context) {
return
}
newZone, err := zc.zoneCorrectionService.Apply(user, domain, zone, &form)
newZone, err := zc.zoneCorrectionService.Apply(c.Request.Context(), user, domain, zone, &form)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
@ -223,6 +248,46 @@ func (zc *ZoneController) ApplyZoneCorrections(c *gin.Context) {
c.JSON(http.StatusOK, newZone.ZoneMeta)
}
// PrepareZoneCorrections computes the executable corrections without applying them.
//
// @Summary Preview the corrections the provider will execute.
// @Schemes
// @Description Compute the executable corrections for the selected changes without applying them.
// @Tags zones
// @Accept json
// @Produce json
// @Security securitydefinitions.basic
// @Param domainId path string true "Domain identifier"
// @Param zoneId path string true "Zone identifier"
// @Param body body happydns.PrepareZoneForm true "Selected corrections to prepare"
// @Success 200 {object} happydns.PrepareZoneResponse "The executable corrections"
// @Failure 400 {object} happydns.ErrorResponse "Invalid input"
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
// @Failure 404 {object} happydns.ErrorResponse "Domain or Zone not found"
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domainId}/zone/{zoneId}/prepare_changes [post]
func (zc *ZoneController) PrepareZoneCorrections(c *gin.Context) {
user := c.MustGet("LoggedUser").(*happydns.User)
domain := c.MustGet("domain").(*happydns.Domain)
zone := c.MustGet("zone").(*happydns.Zone)
var form happydns.PrepareZoneForm
err := c.ShouldBindJSON(&form)
if err != nil {
log.Printf("%s sends invalid PrepareZoneForm JSON: %s", c.ClientIP(), err.Error())
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": fmt.Sprintf("Something is wrong in received data: %s", err.Error())})
return
}
response, err := zc.zoneCorrectionService.Prepare(c.Request.Context(), user, domain, zone, &form)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, response)
}
// ExportZone creates a flatten export of the zone.
//
// @Summary Get flatten zone file.

View file

@ -43,3 +43,21 @@ func ServiceIdHandler(suService happydns.ServiceUsecase) gin.HandlerFunc {
c.Next()
}
}
func ServiceHandler(suService happydns.ServiceUsecase) gin.HandlerFunc {
return func(c *gin.Context) {
zone := c.MustGet("zone").(*happydns.Zone)
serviceid := c.MustGet("serviceid").(happydns.Identifier)
subdomain := c.MustGet("subdomain").(happydns.Subdomain)
_, svc := zone.FindSubdomainService(subdomain, serviceid)
if svc == nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": fmt.Sprintf("Service not found: %s", serviceid)})
return
}
c.Set("service", svc)
c.Next()
}
}

View file

@ -32,7 +32,14 @@ import (
"git.happydns.org/happyDomain/model"
)
func DeclareAuthenticationRoutes(cfg *happydns.Options, baserouter, apirouter *gin.RouterGroup, authUC happydns.AuthenticationUsecase, captchaVerifier happydns.CaptchaVerifier, failureTracker happydns.FailureTracker) *controller.LoginController {
func DeclareAuthenticationRoutes(
cfg *happydns.Options,
baserouter,
apirouter *gin.RouterGroup,
authUC happydns.AuthenticationUsecase,
captchaVerifier happydns.CaptchaVerifier,
failureTracker happydns.FailureTracker,
) *controller.LoginController {
lc := controller.NewLoginController(authUC, captchaVerifier, failureTracker)
apirouter.POST("/auth", lc.Login)

View file

@ -0,0 +1,93 @@
// 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
package route
import (
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/controller"
happydns "git.happydns.org/happyDomain/model"
)
func DeclareCheckersRoutes(router *gin.RouterGroup, checkerUC happydns.CheckerUsecase) *controller.CheckerController {
tpc := controller.NewCheckerController(checkerUC)
router.GET("/checks", tpc.ListCheckers)
apiCheckRoutes := router.Group("/checks/:cname")
apiCheckRoutes.Use(tpc.CheckerHandler)
apiCheckRoutes.GET("", tpc.GetCheckerStatus)
DeclareCheckerOptionsRoutes(apiCheckRoutes, tpc)
return tpc
}
func DeclareScopedCheckersRoutes(
scopedRouter *gin.RouterGroup,
checkerUC happydns.CheckerUsecase,
checkResultUC happydns.CheckResultUsecase,
checkScheduler happydns.SchedulerUsecase,
scope happydns.CheckScopeType,
tpc *controller.CheckerController,
) {
tc := controller.NewCheckResultController(
scope,
checkerUC,
checkResultUC,
checkScheduler,
)
// List all available tests with their status
scopedRouter.GET("/checks", tc.ListAvailableChecks)
apiChecksRoutes := scopedRouter.Group("/checks/:cname")
{
DeclareCheckerOptionsRoutes(apiChecksRoutes, tpc)
// Get latest results for a test
apiChecksRoutes.GET("", tc.ListLatestCheckResults)
// Trigger an on-demand test
apiChecksRoutes.POST("", tc.TriggerCheck)
// Check execution routes
apiCheckExecutionsRoutes := apiChecksRoutes.Group("/executions/:execution_id")
{
apiCheckExecutionsRoutes.GET("", tc.GetCheckExecutionStatus)
}
DeclareScopedCheckResultRoutes(apiChecksRoutes, tc)
}
}
func DeclareCheckerOptionsRoutes(apiCheckRoutes *gin.RouterGroup, tpc *controller.CheckerController) {
apiCheckRoutes.GET("/options", tpc.GetCheckerOptions)
apiCheckRoutes.POST("/options", tpc.AddCheckerOptions)
apiCheckRoutes.PUT("/options", tpc.ChangeCheckerOptions)
apiCheckOptionsRoutes := apiCheckRoutes.Group("/options/:optname")
apiCheckOptionsRoutes.Use(tpc.CheckerOptionHandler)
apiCheckOptionsRoutes.GET("", tpc.GetCheckerOption)
apiCheckOptionsRoutes.PUT("", tpc.SetCheckerOption)
}

View file

@ -0,0 +1,46 @@
// 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
package route
import (
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/controller"
)
// DeclareScopedCheckResultRoutes declares test result routes for a specific scope (domain, zone, or service)
func DeclareScopedCheckResultRoutes(apiChecksRoutes *gin.RouterGroup, tc *controller.CheckResultController) {
// Check metrics route
apiChecksRoutes.GET("/metrics", tc.GetCheckResultMetrics)
// Check results routes
apiChecksRoutes.GET("/results", tc.ListCheckResults)
apiChecksRoutes.DELETE("/results", tc.DropCheckResults)
apiCheckResultsRoutes := apiChecksRoutes.Group("/results/:result_id")
{
apiCheckResultsRoutes.GET("", tc.GetCheckResult)
apiCheckResultsRoutes.DELETE("", tc.DropCheckResult)
apiCheckResultsRoutes.GET("/report", tc.GetCheckResultHTMLReport)
apiCheckResultsRoutes.GET("/metrics", tc.GetSingleCheckResultMetrics)
}
}

View file

@ -0,0 +1,47 @@
// 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
package route
import (
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/controller"
"git.happydns.org/happyDomain/model"
)
// DeclareTestScheduleRoutes declares test schedule management routes
func DeclareTestScheduleRoutes(router *gin.RouterGroup, checkerScheduleUC happydns.CheckerScheduleUsecase) {
sc := controller.NewCheckerScheduleController(checkerScheduleUC)
schedulesRoutes := router.Group("/plugins/tests/schedules")
{
schedulesRoutes.GET("", sc.ListCheckerSchedules)
schedulesRoutes.POST("", sc.CreateCheckerSchedule)
scheduleRoutes := schedulesRoutes.Group("/:schedule_id")
{
scheduleRoutes.GET("", sc.GetCheckerSchedule)
scheduleRoutes.PUT("", sc.UpdateCheckerSchedule)
scheduleRoutes.DELETE("", sc.DeleteCheckerSchedule)
}
}
}

View file

@ -29,11 +29,26 @@ import (
"git.happydns.org/happyDomain/model"
)
func DeclareDomainRoutes(router *gin.RouterGroup, domainUC happydns.DomainUsecase, domainLogUC happydns.DomainLogUsecase, remoteZoneImporter happydns.RemoteZoneImporterUsecase, zoneImporter happydns.ZoneImporterUsecase, zoneUC happydns.ZoneUsecase, zoneCorrApplier happydns.ZoneCorrectionApplierUsecase, zoneServiceUC happydns.ZoneServiceUsecase, serviceUC happydns.ServiceUsecase) {
func DeclareDomainRoutes(
router *gin.RouterGroup,
domainUC happydns.DomainUsecase,
domainLogUC happydns.DomainLogUsecase,
remoteZoneImporter happydns.RemoteZoneImporterUsecase,
zoneImporter happydns.ZoneImporterUsecase,
zoneUC happydns.ZoneUsecase,
zoneCorrApplier happydns.ZoneCorrectionApplierUsecase,
zoneServiceUC happydns.ZoneServiceUsecase,
serviceUC happydns.ServiceUsecase,
checkerUC happydns.CheckerUsecase,
checkResultUC happydns.CheckResultUsecase,
checkScheduler happydns.SchedulerUsecase,
tpc *controller.CheckerController,
) {
dc := controller.NewDomainController(
domainUC,
remoteZoneImporter,
zoneImporter,
checkResultUC,
)
router.GET("/domains", dc.GetDomains)
@ -48,8 +63,30 @@ func DeclareDomainRoutes(router *gin.RouterGroup, domainUC happydns.DomainUsecas
DeclareDomainLogRoutes(apiDomainsRoutes, domainLogUC)
// Declare test result routes for domain scope
DeclareScopedCheckersRoutes(
apiDomainsRoutes,
checkerUC,
checkResultUC,
checkScheduler,
happydns.CheckScopeDomain,
tpc,
)
apiDomainsRoutes.POST("/zone", dc.ImportZone)
apiDomainsRoutes.POST("/retrieve_zone", dc.RetrieveZone)
DeclareZoneRoutes(apiDomainsRoutes, zoneUC, domainUC, zoneCorrApplier, zoneServiceUC, serviceUC)
DeclareZoneRoutes(
apiDomainsRoutes,
zoneUC,
domainUC,
zoneCorrApplier,
zoneServiceUC,
serviceUC,
checkerUC,
checkResultUC,
checkScheduler,
tpc,
)
}

View file

@ -34,6 +34,10 @@ type Dependencies struct {
Authentication happydns.AuthenticationUsecase
AuthUser happydns.AuthUserUsecase
CaptchaVerifier happydns.CaptchaVerifier
Checker happydns.CheckerUsecase
CheckResult happydns.CheckResultUsecase
CheckerSchedule happydns.CheckerScheduleUsecase
CheckScheduler happydns.SchedulerUsecase
Domain happydns.DomainUsecase
DomainLog happydns.DomainLogUsecase
FailureTracker happydns.FailureTracker
@ -79,7 +83,14 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, dep Dependenc
apiRoutes := router.Group("/api")
lc := DeclareAuthenticationRoutes(cfg, baseRoutes, apiRoutes, dep.Authentication, dep.CaptchaVerifier, dep.FailureTracker)
lc := DeclareAuthenticationRoutes(
cfg,
baseRoutes,
apiRoutes,
dep.Authentication,
dep.CaptchaVerifier,
dep.FailureTracker,
)
auc := DeclareAuthUserRoutes(apiRoutes, dep.AuthUser, lc)
DeclareProviderSpecsRoutes(apiRoutes, dep.ProviderSpecs)
DeclareRegistrationRoutes(apiRoutes, dep.AuthUser, dep.CaptchaVerifier)
@ -99,10 +110,26 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, dep Dependenc
apiAuthRoutes.Use(middleware.AuthRequired())
DeclareAuthenticationCheckRoutes(apiAuthRoutes, lc)
DeclareDomainRoutes(apiAuthRoutes, dep.Domain, dep.DomainLog, dep.RemoteZoneImporter, dep.ZoneImporter, dep.Zone, dep.ZoneCorrectionApplier, dep.ZoneService, dep.Service)
tpc := DeclareCheckersRoutes(apiAuthRoutes, dep.Checker)
DeclareDomainRoutes(
apiAuthRoutes,
dep.Domain,
dep.DomainLog,
dep.RemoteZoneImporter,
dep.ZoneImporter,
dep.Zone,
dep.ZoneCorrectionApplier,
dep.ZoneService,
dep.Service,
dep.Checker,
dep.CheckResult,
dep.CheckScheduler,
tpc,
)
DeclareProviderRoutes(apiAuthRoutes, dep.Provider)
DeclareProviderSettingsRoutes(apiAuthRoutes, dep.ProviderSettings)
DeclareRecordRoutes(apiAuthRoutes)
DeclareTestScheduleRoutes(apiAuthRoutes, dep.CheckerSchedule)
DeclareUsersRoutes(apiAuthRoutes, dep.User, lc)
DeclareSessionRoutes(apiAuthRoutes, dep.Session)
}

View file

@ -29,15 +29,39 @@ import (
"git.happydns.org/happyDomain/model"
)
func DeclareZoneServiceRoutes(apiZonesRoutes, apiZonesSubdomainRoutes *gin.RouterGroup, zc *controller.ZoneController, zoneServiceUC happydns.ZoneServiceUsecase, serviceUC happydns.ServiceUsecase, zoneUC happydns.ZoneUsecase) {
sc := controller.NewServiceController(zoneServiceUC, serviceUC, zoneUC)
func DeclareZoneServiceRoutes(
apiZonesRoutes,
apiZonesSubdomainRoutes *gin.RouterGroup,
zc *controller.ZoneController,
zoneServiceUC happydns.ZoneServiceUsecase,
serviceUC happydns.ServiceUsecase,
zoneUC happydns.ZoneUsecase,
checkerUC happydns.CheckerUsecase,
checkResultUC happydns.CheckResultUsecase,
checkScheduler happydns.SchedulerUsecase,
tpc *controller.CheckerController,
) {
sc := controller.NewServiceController(zoneServiceUC, serviceUC, zoneUC, checkResultUC)
apiZonesRoutes.PATCH("", sc.UpdateZoneService)
apiZonesSubdomainRoutes.POST("/services", sc.AddZoneService)
apiZonesSubdomainServiceIdRoutes := apiZonesSubdomainRoutes.Group("/services/:serviceid")
apiZonesSubdomainServiceIdRoutes.Use(middleware.ServiceIdHandler(serviceUC))
apiZonesSubdomainServiceIdRoutes.GET("", sc.GetZoneService)
apiZonesSubdomainServiceIdRoutes.DELETE("", sc.DeleteZoneService)
apiZonesSubdomainServiceIDRoutes := apiZonesSubdomainRoutes.Group("/services/:serviceid")
apiZonesSubdomainServiceIDRoutes.Use(middleware.ServiceIdHandler(serviceUC))
apiZonesSubdomainServiceIDRoutes.DELETE("", sc.DeleteZoneService)
apiZonesSubdomainServiceRoutes := apiZonesSubdomainRoutes.Group("/services/:serviceid")
apiZonesSubdomainServiceRoutes.Use(middleware.ServiceIdHandler(serviceUC), middleware.ServiceHandler(serviceUC))
apiZonesSubdomainServiceRoutes.GET("", sc.GetZoneService)
// Declare test result routes for service scope
DeclareScopedCheckersRoutes(
apiZonesSubdomainServiceRoutes,
checkerUC,
checkResultUC,
checkScheduler,
happydns.CheckScopeService,
tpc,
)
}

View file

@ -41,4 +41,5 @@ func DeclareServiceSpecsRoutes(router *gin.RouterGroup, serviceSpecsUC happydns.
apiServiceSpecsRoutes.GET("", ssc.GetServiceSpec)
apiServiceSpecsRoutes.POST("/init", ssc.InitializeServiceSpec)
apiServiceSpecsRoutes.POST("/records", ssc.GenerateRecords)
}

View file

@ -29,11 +29,23 @@ import (
happydns "git.happydns.org/happyDomain/model"
)
func DeclareZoneRoutes(router *gin.RouterGroup, zoneUC happydns.ZoneUsecase, domainUC happydns.DomainUsecase, zoneCorrApplier happydns.ZoneCorrectionApplierUsecase, zoneServiceUC happydns.ZoneServiceUsecase, serviceUC happydns.ServiceUsecase) {
func DeclareZoneRoutes(
router *gin.RouterGroup,
zoneUC happydns.ZoneUsecase,
domainUC happydns.DomainUsecase,
zoneCorrApplier happydns.ZoneCorrectionApplierUsecase,
zoneServiceUC happydns.ZoneServiceUsecase,
serviceUC happydns.ServiceUsecase,
checkerUC happydns.CheckerUsecase,
checkResultUC happydns.CheckResultUsecase,
checkScheduler happydns.SchedulerUsecase,
tpc *controller.CheckerController,
) {
zc := controller.NewZoneController(
zoneUC,
domainUC,
zoneCorrApplier,
checkResultUC,
)
apiZonesRoutes := router.Group("/zone/:zoneid")
@ -44,13 +56,25 @@ func DeclareZoneRoutes(router *gin.RouterGroup, zoneUC happydns.ZoneUsecase, dom
apiZonesRoutes.POST("/diff/:oldzoneid", zc.DiffZonesHandler, zc.DiffZones)
apiZonesRoutes.POST("/diff/:oldzoneid/summary", zc.DiffZonesHandler, zc.DiffZonesSummary)
apiZonesRoutes.POST("/view", zc.ExportZone)
apiZonesRoutes.POST("/prepare_changes", zc.PrepareZoneCorrections)
apiZonesRoutes.POST("/apply_changes", zc.ApplyZoneCorrections)
apiZonesSubdomainRoutes := apiZonesRoutes.Group("/:subdomain")
apiZonesSubdomainRoutes.Use(middleware.SubdomainHandler)
apiZonesSubdomainRoutes.GET("", zc.GetZoneSubdomain)
DeclareZoneServiceRoutes(apiZonesRoutes, apiZonesSubdomainRoutes, zc, zoneServiceUC, serviceUC, zoneUC)
DeclareZoneServiceRoutes(
apiZonesRoutes,
apiZonesSubdomainRoutes,
zc,
zoneServiceUC,
serviceUC,
zoneUC,
checkerUC,
checkResultUC,
checkScheduler,
tpc,
)
apiZonesRoutes.POST("/records", zc.AddRecords)
apiZonesRoutes.POST("/records/delete", zc.DeleteRecords)

View file

@ -54,20 +54,27 @@ func NewAdmin(app *App) *Admin {
router.Use(gin.Logger(), gin.Recovery())
// Prepare usecases (admin uses unrestricted provider access)
app.usecases.providerAdmin = providerUC.NewService(app.store)
app.usecases.providerAdmin = providerUC.NewService(app.store, nil)
admin.DeclareRoutes(app.cfg, router, app.store, admin.Dependencies{
AuthUser: app.usecases.authUser,
Domain: app.usecases.domain,
Provider: app.usecases.providerAdmin,
RemoteZoneImporter: app.usecases.orchestrator.RemoteZoneImporter,
Service: app.usecases.service,
User: app.usecases.user,
Zone: app.usecases.zone,
ZoneCorrectionApplier: app.usecases.orchestrator.ZoneCorrectionApplier,
ZoneImporter: app.usecases.orchestrator.ZoneImporter,
ZoneService: app.usecases.zoneService,
})
admin.DeclareRoutes(
app.cfg,
router,
app.store,
admin.Dependencies{
AuthUser: app.usecases.authUser,
Checker: app.usecases.checker,
CheckScheduler: app.checkScheduler,
Domain: app.usecases.domain,
Provider: app.usecases.providerAdmin,
RemoteZoneImporter: app.usecases.orchestrator.RemoteZoneImporter,
Service: app.usecases.service,
User: app.usecases.user,
Zone: app.usecases.zone,
ZoneCorrectionApplier: app.usecases.orchestrator.ZoneCorrectionApplier,
ZoneImporter: app.usecases.orchestrator.ZoneImporter,
ZoneService: app.usecases.zoneService,
},
)
web.DeclareRoutes(app.cfg, router)
return &Admin{

View file

@ -38,6 +38,8 @@ import (
"git.happydns.org/happyDomain/internal/storage"
"git.happydns.org/happyDomain/internal/usecase"
authuserUC "git.happydns.org/happyDomain/internal/usecase/authuser"
checkUC "git.happydns.org/happyDomain/internal/usecase/check"
checkresultUC "git.happydns.org/happyDomain/internal/usecase/checkresult"
domainUC "git.happydns.org/happyDomain/internal/usecase/domain"
domainlogUC "git.happydns.org/happyDomain/internal/usecase/domain_log"
"git.happydns.org/happyDomain/internal/usecase/orchestrator"
@ -54,6 +56,9 @@ import (
type Usecases struct {
authentication happydns.AuthenticationUsecase
authUser happydns.AuthUserUsecase
checker happydns.CheckerUsecase
checkResult happydns.CheckResultUsecase
checkerSchedule happydns.CheckerScheduleUsecase
domain happydns.DomainUsecase
domainLog happydns.DomainLogUsecase
provider happydns.ProviderUsecase
@ -76,11 +81,12 @@ type App struct {
cfg *happydns.Options
failureTracker *captcha.FailureTracker
insights *insightsCollector
mailer *mailer.Mailer
mailer happydns.Mailer
newsletter happydns.NewsletterSubscriptor
router *gin.Engine
srv *http.Server
store storage.Storage
checkScheduler happydns.SchedulerUsecase
usecases Usecases
}
@ -93,8 +99,12 @@ func NewApp(cfg *happydns.Options) *App {
app.initStorageEngine()
app.initNewsletter()
app.initInsights()
if err := app.initPlugins(); err != nil {
log.Fatalf("Plugin initialization error: %s", err)
}
app.initUsecases()
app.initCaptcha()
app.initCheckScheduler()
app.setupRouter()
return app
@ -108,8 +118,12 @@ func NewAppWithStorage(cfg *happydns.Options, store storage.Storage) *App {
app.initMailer()
app.initNewsletter()
if err := app.initPlugins(); err != nil {
log.Fatalf("Plugin initialization error: %s", err)
}
app.initUsecases()
app.initCaptcha()
app.initCheckScheduler()
app.setupRouter()
return app
@ -128,19 +142,22 @@ func (app *App) initCaptcha() {
func (app *App) initMailer() {
if app.cfg.MailSMTPHost != "" {
app.mailer = &mailer.Mailer{
m := &mailer.Mailer{
MailFrom: &app.cfg.MailFrom,
SendMethod: mailer.NewSMTPMailer(app.cfg.MailSMTPHost, app.cfg.MailSMTPPort, app.cfg.MailSMTPUsername, app.cfg.MailSMTPPassword),
}
if app.cfg.MailSMTPTLSSNoVerify {
app.mailer.SendMethod.(*mailer.SMTPMailer).WithTLSNoVerify()
m.SendMethod.(*mailer.SMTPMailer).WithTLSNoVerify()
}
app.mailer = m
} else if !app.cfg.NoMail {
app.mailer = &mailer.Mailer{
MailFrom: &app.cfg.MailFrom,
SendMethod: &mailer.SystemSendmail{},
}
} else {
app.mailer = &mailer.LogMailer{}
}
}
@ -183,33 +200,73 @@ func (app *App) initInsights() {
}
}
func (app *App) initCheckScheduler() {
if app.cfg.DisableScheduler {
// Use a disabled scheduler that returns clear errors
app.checkScheduler = &disabledScheduler{}
return
}
app.checkScheduler = newCheckScheduler(
app.cfg,
app.store,
app.usecases.checker,
app.usecases.checkResult,
app.usecases.checkerSchedule,
)
}
func (app *App) initUsecases() {
sessionService := sessionUC.NewService(app.store)
authUserService := authuserUC.NewAuthUserUsecases(app.cfg, app.mailer, app.store, sessionService)
authUserService := authuserUC.NewAuthUserUsecases(
app.cfg,
app.mailer,
app.store,
sessionService,
)
domainLogService := domainlogUC.NewService(app.store)
providerService := providerUC.NewRestrictedService(app.cfg, app.store)
providerAdminService := providerUC.NewService(app.store)
providerAdminService := providerUC.NewService(app.store, nil)
serviceService := serviceUC.NewServiceUsecases()
zoneService := zoneUC.NewZoneUsecases(app.store, serviceService)
app.usecases.providerSpecs = usecase.NewProviderSpecsUsecase()
app.usecases.provider = providerService
app.usecases.providerAdmin = providerAdminService
app.usecases.providerSettings = usecase.NewProviderSettingsUsecase(app.cfg, app.usecases.provider, app.store)
app.usecases.providerSettings = usecase.NewProviderSettingsUsecase(app.cfg, app.usecases.provider)
app.usecases.service = serviceService
app.usecases.serviceSpecs = usecase.NewServiceSpecsUsecase()
app.usecases.zone = zoneService
app.usecases.domainLog = domainLogService
domainService := domainUC.NewService(app.store, providerAdminService, zoneService.GetZoneUC, providerAdminService, domainLogService)
domainService := domainUC.NewService(
app.store,
providerAdminService,
zoneService.GetZoneUC,
providerAdminService,
domainLogService,
)
app.usecases.domain = domainService
app.usecases.zoneService = zoneServiceUC.NewZoneServiceUsecases(domainService, zoneService.CreateZoneUC, serviceService.ValidateServiceUC, app.store)
app.usecases.zoneService = zoneServiceUC.NewZoneServiceUsecases(
domainService,
zoneService.CreateZoneUC,
serviceService.ValidateServiceUC,
app.store,
)
app.usecases.user = userUC.NewUserUsecases(app.store, app.newsletter, authUserService, sessionService)
app.usecases.user = userUC.NewUserUsecases(
app.store,
app.newsletter,
authUserService,
sessionService,
)
app.usecases.authentication = usecase.NewAuthenticationUsecase(app.cfg, app.store, app.usecases.user)
app.usecases.authUser = authUserService
app.usecases.resolver = usecase.NewResolverUsecase(app.cfg)
app.usecases.session = sessionService
app.usecases.checker = checkUC.NewCheckerUsecase(app.cfg, app.store, app.store)
app.usecases.checkerSchedule = checkresultUC.NewCheckScheduleUsecase(app.store, app.cfg, app.store, app.usecases.checker)
app.usecases.checkResult = checkresultUC.NewCheckResultUsecase(app.store, app.cfg, app.usecases.checker, app.usecases.checkerSchedule)
app.usecases.orchestrator = orchestrator.NewOrchestrator(
domainLogService,
@ -218,6 +275,7 @@ func (app *App) initUsecases() {
zoneService.ListRecordsUC,
providerAdminService,
zoneService.CreateZoneUC,
zoneService.GetZoneUC,
providerAdminService,
zoneService.UpdateZoneUC,
)
@ -243,27 +301,35 @@ func (app *App) setupRouter() {
baserouter := app.router.Group(app.cfg.BasePath)
api.DeclareRoutes(app.cfg, baserouter, api.Dependencies{
Authentication: app.usecases.authentication,
AuthUser: app.usecases.authUser,
CaptchaVerifier: app.captchaVerifier,
Domain: app.usecases.domain,
DomainLog: app.usecases.domainLog,
FailureTracker: app.failureTracker,
Provider: app.usecases.provider,
ProviderSettings: app.usecases.providerSettings,
ProviderSpecs: app.usecases.providerSpecs,
RemoteZoneImporter: app.usecases.orchestrator.RemoteZoneImporter,
Resolver: app.usecases.resolver,
Service: app.usecases.service,
ServiceSpecs: app.usecases.serviceSpecs,
Session: app.usecases.session,
User: app.usecases.user,
Zone: app.usecases.zone,
ZoneCorrectionApplier: app.usecases.orchestrator.ZoneCorrectionApplier,
ZoneImporter: app.usecases.orchestrator.ZoneImporter,
ZoneService: app.usecases.zoneService,
})
api.DeclareRoutes(
app.cfg,
baserouter,
api.Dependencies{
Authentication: app.usecases.authentication,
AuthUser: app.usecases.authUser,
CaptchaVerifier: app.captchaVerifier,
Checker: app.usecases.checker,
CheckResult: app.usecases.checkResult,
CheckerSchedule: app.usecases.checkerSchedule,
CheckScheduler: app.checkScheduler,
Domain: app.usecases.domain,
DomainLog: app.usecases.domainLog,
FailureTracker: app.failureTracker,
Provider: app.usecases.provider,
ProviderSettings: app.usecases.providerSettings,
ProviderSpecs: app.usecases.providerSpecs,
RemoteZoneImporter: app.usecases.orchestrator.RemoteZoneImporter,
Resolver: app.usecases.resolver,
Service: app.usecases.service,
ServiceSpecs: app.usecases.serviceSpecs,
Session: app.usecases.session,
User: app.usecases.user,
Zone: app.usecases.zone,
ZoneCorrectionApplier: app.usecases.orchestrator.ZoneCorrectionApplier,
ZoneImporter: app.usecases.orchestrator.ZoneImporter,
ZoneService: app.usecases.zoneService,
},
)
web.DeclareRoutes(app.cfg, baserouter, app.captchaVerifier)
web.NoRoute(app.cfg, app.router)
}
@ -279,6 +345,8 @@ func (app *App) Start() {
go app.insights.Run()
}
go app.checkScheduler.Run()
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)
@ -304,4 +372,6 @@ func (app *App) Stop() {
if app.failureTracker != nil {
app.failureTracker.Close()
}
app.checkScheduler.Close()
}

View file

@ -0,0 +1,622 @@
// 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
package app
import (
"container/heap"
"context"
"fmt"
"log"
"runtime"
"sync"
"time"
"git.happydns.org/happyDomain/internal/storage"
"git.happydns.org/happyDomain/model"
)
const (
SchedulerCheckInterval = 1 * time.Minute // How often to check for due tests
SchedulerCleanupInterval = 24 * time.Hour // How often to clean up old executions
SchedulerDiscoveryInterval = 1 * time.Hour // How often to auto-discover new targets
CheckExecutionTimeout = 5 * time.Minute // Max time for a single check
MaxRetries = 3 // Max retry attempts for failed checks
)
// Priority levels for test execution queue
const (
PriorityOnDemand = iota // On-demand tests (highest priority)
PriorityOverdue // Overdue scheduled tests
PriorityScheduled // Regular scheduled tests
)
// checkScheduler manages background test execution
type checkScheduler struct {
cfg *happydns.Options
store storage.Storage
checkerUsecase happydns.CheckerUsecase
resultUsecase happydns.CheckResultUsecase
scheduleUsecase happydns.CheckerScheduleUsecase
stop chan struct{} // closed to stop the main Run loop
stopWorkers chan struct{} // closed to stop all workers simultaneously
runNowChan chan *queueItem // on-demand items routed through the main loop
workAvail chan struct{} // non-blocking signals that queue has new work
queue *priorityQueue
activeExecutions map[string]*activeExecution
workers []*worker
mu sync.RWMutex
wg sync.WaitGroup
runtimeEnabled bool
running bool
}
// activeExecution tracks a running test execution
type activeExecution struct {
execution *happydns.CheckExecution
cancel context.CancelFunc
startTime time.Time
}
// queueItem represents a test execution request in the queue
type queueItem struct {
schedule *happydns.CheckerSchedule
execution *happydns.CheckExecution
priority int
queuedAt time.Time
retries int
}
// --- container/heap implementation for priorityQueue ---
// priorityHeap is the underlying heap, ordered by priority then arrival time.
type priorityHeap []*queueItem
func (h priorityHeap) Len() int { return len(h) }
func (h priorityHeap) Less(i, j int) bool {
if h[i].priority != h[j].priority {
return h[i].priority < h[j].priority
}
return h[i].queuedAt.Before(h[j].queuedAt)
}
func (h priorityHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *priorityHeap) Push(x any) { *h = append(*h, x.(*queueItem)) }
func (h *priorityHeap) Pop() any {
old := *h
n := len(old)
x := old[n-1]
old[n-1] = nil // avoid memory leak
*h = old[:n-1]
return x
}
// priorityQueue is a thread-safe min-heap of queueItems.
type priorityQueue struct {
h priorityHeap
mu sync.Mutex
}
func newPriorityQueue() *priorityQueue {
pq := &priorityQueue{}
heap.Init(&pq.h)
return pq
}
// Push adds an item to the queue.
func (q *priorityQueue) Push(item *queueItem) {
q.mu.Lock()
defer q.mu.Unlock()
heap.Push(&q.h, item)
}
// Pop removes and returns the highest-priority item, or nil if empty.
func (q *priorityQueue) Pop() *queueItem {
q.mu.Lock()
defer q.mu.Unlock()
if q.h.Len() == 0 {
return nil
}
return heap.Pop(&q.h).(*queueItem)
}
// Len returns the queue length.
func (q *priorityQueue) Len() int {
q.mu.Lock()
defer q.mu.Unlock()
return q.h.Len()
}
// worker processes tests from the queue
type worker struct {
id int
scheduler *checkScheduler
}
// newCheckScheduler creates a new test scheduler
func newCheckScheduler(
cfg *happydns.Options,
store storage.Storage,
checkerUsecase happydns.CheckerUsecase,
resultUsecase happydns.CheckResultUsecase,
scheduleUsecase happydns.CheckerScheduleUsecase,
) *checkScheduler {
numWorkers := cfg.TestWorkers
if numWorkers <= 0 {
numWorkers = runtime.NumCPU()
}
scheduler := &checkScheduler{
cfg: cfg,
store: store,
checkerUsecase: checkerUsecase,
resultUsecase: resultUsecase,
scheduleUsecase: scheduleUsecase,
stop: make(chan struct{}),
stopWorkers: make(chan struct{}),
runNowChan: make(chan *queueItem, 100),
workAvail: make(chan struct{}, numWorkers),
queue: newPriorityQueue(),
activeExecutions: make(map[string]*activeExecution),
workers: make([]*worker, numWorkers),
runtimeEnabled: true,
}
for i := 0; i < numWorkers; i++ {
scheduler.workers[i] = &worker{
id: i,
scheduler: scheduler,
}
}
return scheduler
}
// enqueue pushes an item to the priority queue and wakes one idle worker.
func (s *checkScheduler) enqueue(item *queueItem) {
s.queue.Push(item)
select {
case s.workAvail <- struct{}{}:
default:
// All workers are already busy or already notified; they will drain
// the queue on their own after finishing the current item.
}
}
// Close stops the scheduler and waits for all workers to finish.
func (s *checkScheduler) Close() {
log.Println("Stopping test scheduler...")
// Unblock the main Run loop.
close(s.stop)
// Unblock all workers simultaneously.
close(s.stopWorkers)
// Cancel all active test executions.
s.mu.Lock()
for _, exec := range s.activeExecutions {
exec.cancel()
}
s.mu.Unlock()
// Wait for all workers to finish their current item.
s.wg.Wait()
log.Println("Check scheduler stopped")
}
// Run starts the scheduler main loop. It must not be called more than once.
func (s *checkScheduler) Run() {
s.mu.Lock()
s.running = true
s.mu.Unlock()
defer func() {
s.mu.Lock()
s.running = false
s.mu.Unlock()
}()
log.Printf("Starting test scheduler with %d workers...\n", len(s.workers))
// Reschedule overdue tests before starting workers so that tests missed
// during a server suspend or shutdown are spread into the near future
// instead of all firing at once.
if n, err := s.scheduleUsecase.RescheduleOverdueChecks(); err != nil {
log.Printf("Warning: failed to reschedule overdue tests: %v\n", err)
} else if n > 0 {
log.Printf("Rescheduled %d overdue test(s) into the near future\n", n)
}
// Start workers
for _, w := range s.workers {
s.wg.Add(1)
go w.run(&s.wg)
}
// Main scheduling loop
checkTicker := time.NewTicker(SchedulerCheckInterval)
cleanupTicker := time.NewTicker(SchedulerCleanupInterval)
discoveryTicker := time.NewTicker(SchedulerDiscoveryInterval)
defer checkTicker.Stop()
defer cleanupTicker.Stop()
defer discoveryTicker.Stop()
// Initial discovery: create default schedules for all existing targets
if err := s.scheduleUsecase.DiscoverAndEnsureSchedules(); err != nil {
log.Printf("Warning: schedule discovery encountered errors: %v\n", err)
}
// Initial check
s.checkSchedules()
for {
select {
case <-checkTicker.C:
s.checkSchedules()
case <-cleanupTicker.C:
s.cleanup()
case <-discoveryTicker.C:
if err := s.scheduleUsecase.DiscoverAndEnsureSchedules(); err != nil {
log.Printf("Warning: schedule discovery encountered errors: %v\n", err)
}
case item := <-s.runNowChan:
s.enqueue(item)
case <-s.stop:
return
}
}
}
// checkSchedules checks for due tests and queues them
func (s *checkScheduler) checkSchedules() {
s.mu.RLock()
enabled := s.runtimeEnabled
s.mu.RUnlock()
if !enabled {
return
}
dueSchedules, err := s.scheduleUsecase.ListDueSchedules()
if err != nil {
log.Printf("Error listing due schedules: %v\n", err)
return
}
now := time.Now()
for _, schedule := range dueSchedules {
// Determine priority based on how overdue the test is
priority := PriorityScheduled
if schedule.NextRun.Add(schedule.Interval).Before(now) {
priority = PriorityOverdue
}
// Create execution record
execution := &happydns.CheckExecution{
ScheduleId: &schedule.Id,
CheckerName: schedule.CheckerName,
OwnerId: schedule.OwnerId,
TargetType: schedule.TargetType,
TargetId: schedule.TargetId,
Status: happydns.CheckExecutionPending,
StartedAt: now,
Options: schedule.Options,
}
if err := s.resultUsecase.CreateCheckExecution(execution); err != nil {
log.Printf("Error creating execution for schedule %s: %v\n", schedule.Id.String(), err)
continue
}
s.enqueue(&queueItem{
schedule: schedule,
execution: execution,
priority: priority,
queuedAt: now,
retries: 0,
})
}
// Mark scheduler run
if err := s.store.CheckSchedulerRun(); err != nil {
log.Printf("Error marking scheduler run: %v\n", err)
}
}
// TriggerOnDemandCheck triggers an immediate test execution.
// It creates the execution record synchronously (so the caller gets an ID back)
// and then routes the item through runNowChan so the main loop controls
// all queue insertions.
func (s *checkScheduler) TriggerOnDemandCheck(checkerName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, ownerId happydns.Identifier, options happydns.CheckerOptions) (happydns.Identifier, error) {
schedule := &happydns.CheckerSchedule{
CheckerName: checkerName,
OwnerId: ownerId,
TargetType: targetType,
TargetId: targetId,
Interval: 0, // On-demand, no interval
Enabled: true,
Options: options,
}
now := time.Now()
execution := &happydns.CheckExecution{
ScheduleId: nil,
CheckerName: checkerName,
OwnerId: ownerId,
TargetType: targetType,
TargetId: targetId,
Status: happydns.CheckExecutionPending,
StartedAt: now,
Options: options,
}
if err := s.resultUsecase.CreateCheckExecution(execution); err != nil {
return happydns.Identifier{}, err
}
item := &queueItem{
schedule: schedule,
execution: execution,
priority: PriorityOnDemand,
queuedAt: now,
retries: 0,
}
// Route through the main loop when possible; fall back to direct enqueue
// if the channel is full so that the caller never blocks.
select {
case s.runNowChan <- item:
default:
s.enqueue(item)
}
return execution.Id, nil
}
// GetSchedulerStatus returns a snapshot of the current scheduler state
func (s *checkScheduler) GetSchedulerStatus() happydns.SchedulerStatus {
s.mu.RLock()
activeCount := len(s.activeExecutions)
running := s.running
runtimeEnabled := s.runtimeEnabled
s.mu.RUnlock()
nextSchedules, _ := s.scheduleUsecase.ListUpcomingSchedules(20)
return happydns.SchedulerStatus{
ConfigEnabled: !s.cfg.DisableScheduler,
RuntimeEnabled: runtimeEnabled,
Running: running,
WorkerCount: len(s.workers),
QueueSize: s.queue.Len(),
ActiveCount: activeCount,
NextSchedules: nextSchedules,
}
}
// SetEnabled enables or disables the scheduler at runtime
func (s *checkScheduler) SetEnabled(enabled bool) error {
s.mu.Lock()
wasEnabled := s.runtimeEnabled
s.runtimeEnabled = enabled
s.mu.Unlock()
if enabled && !wasEnabled {
// Spread out any overdue tests to avoid a thundering herd, then
// immediately enqueue whatever is now due.
if n, err := s.scheduleUsecase.RescheduleOverdueChecks(); err != nil {
log.Printf("Warning: failed to reschedule overdue tests on re-enable: %v\n", err)
} else if n > 0 {
log.Printf("Rescheduled %d overdue test(s) after scheduler re-enable\n", n)
}
s.checkSchedules()
}
return nil
}
// RescheduleUpcomingChecks randomizes the next run time of all enabled schedules
// within their respective intervals, delegating to the schedule usecase.
func (s *checkScheduler) RescheduleUpcomingChecks() (int, error) {
return s.scheduleUsecase.RescheduleUpcomingChecks()
}
// cleanup removes old execution records and expired test results
func (s *checkScheduler) cleanup() {
log.Println("Running scheduler cleanup...")
// Delete completed/failed execution records older than 7 days
if err := s.resultUsecase.DeleteCompletedExecutions(7 * 24 * time.Hour); err != nil {
log.Printf("Error cleaning up old executions: %v\n", err)
}
// Delete test results older than the configured retention period
if err := s.resultUsecase.CleanupOldResults(); err != nil {
log.Printf("Error cleaning up old test results: %v\n", err)
}
log.Println("Scheduler cleanup complete")
}
// run is the worker's main loop. It drains the queue eagerly and waits for a
// workAvail signal when idle, rather than sleeping on a fixed timer.
func (w *worker) run(wg *sync.WaitGroup) {
defer wg.Done()
log.Printf("Worker %d started\n", w.id)
for {
// Drain: try to grab work before blocking.
if item := w.scheduler.queue.Pop(); item != nil {
w.executeCheck(item)
continue
}
// Queue is empty; wait for new work or a stop signal.
select {
case <-w.scheduler.workAvail:
// Loop back to attempt a Pop.
case <-w.scheduler.stopWorkers:
log.Printf("Worker %d stopped\n", w.id)
return
}
}
}
// executeCheck runs a checker and stores the result.
func (w *worker) executeCheck(item *queueItem) {
ctx, cancel := context.WithTimeout(context.Background(), CheckExecutionTimeout)
defer cancel()
execution := item.execution
schedule := item.schedule
// Always update schedule NextRun after execution, whether it succeeds or fails.
// This prevents the schedule from being re-queued on the next tick if the test fails.
if execution.ScheduleId != nil {
defer func() {
if err := w.scheduler.scheduleUsecase.UpdateScheduleAfterRun(*execution.ScheduleId); err != nil {
log.Printf("Worker %d: Error updating schedule after run: %v\n", w.id, err)
}
}()
}
// Mark execution as running
execution.Status = happydns.CheckExecutionRunning
if err := w.scheduler.resultUsecase.UpdateCheckExecution(execution); err != nil {
log.Printf("Worker %d: Error updating execution status: %v\n", w.id, err)
_ = w.scheduler.resultUsecase.FailCheckExecution(execution.Id, err.Error())
return
}
// Track active execution
startTime := time.Now()
w.scheduler.mu.Lock()
w.scheduler.activeExecutions[execution.Id.String()] = &activeExecution{
execution: execution,
cancel: cancel,
startTime: startTime,
}
w.scheduler.mu.Unlock()
defer func() {
w.scheduler.mu.Lock()
delete(w.scheduler.activeExecutions, execution.Id.String())
w.scheduler.mu.Unlock()
}()
// Get the checker
checker, err := w.scheduler.checkerUsecase.GetChecker(schedule.CheckerName)
if err != nil {
errMsg := fmt.Sprintf("checker not found: %s - %v", schedule.CheckerName, err)
log.Printf("Worker %d: %s\n", w.id, errMsg)
_ = w.scheduler.resultUsecase.FailCheckExecution(execution.Id, errMsg)
return
}
var domainId, serviceId *happydns.Identifier
switch schedule.TargetType {
case happydns.CheckScopeDomain:
domainId = &schedule.TargetId
case happydns.CheckScopeService:
serviceId = &schedule.TargetId
}
// Merge options: global defaults < user opts < domain/service opts < schedule/on-demand opts < auto-fill
mergedOptions, mergeErr := w.scheduler.checkerUsecase.BuildMergedCheckerOptions(schedule.CheckerName, &schedule.OwnerId, domainId, serviceId, schedule.Options)
if mergeErr != nil {
// Non-fatal: fall back to schedule-only options
log.Printf("Worker %d: warning, could not prepare checker options for %s: %v\n", w.id, schedule.CheckerName, mergeErr)
mergedOptions = schedule.Options
}
// Prepare metadata
meta := map[string]string{
"target_type": schedule.TargetType.String(),
"target_id": schedule.TargetId.String(),
}
// Run the check synchronously with context-based timeout.
// The checker is responsible for honouring ctx cancellation.
checkResult, testErr := w.runCheckSafe(ctx, checker, mergedOptions, meta)
duration := time.Since(startTime)
// Build the result record, enriching the partial CheckResult from RunCheck
// with execution metadata (owner, target, duration, etc.).
result := &happydns.CheckResult{
CheckerName: schedule.CheckerName,
CheckType: schedule.TargetType,
TargetId: schedule.TargetId,
OwnerId: schedule.OwnerId,
ExecutedAt: startTime,
ScheduledCheck: execution.ScheduleId != nil,
Options: schedule.Options,
Duration: duration,
}
if checkResult != nil {
result.Status = checkResult.Status
result.StatusLine = checkResult.StatusLine
result.Report = checkResult.Report
if testErr != nil {
result.Error = testErr.Error()
}
} else {
result.Status = happydns.CheckResultStatusUnknown
result.StatusLine = "Check execution failed"
if testErr != nil {
result.Error = testErr.Error()
} else {
result.Error = "Unknown check execution error"
}
}
// Save the result
if err := w.scheduler.resultUsecase.CreateCheckResult(result); err != nil {
log.Printf("Worker %d: Error saving test result: %v\n", w.id, err)
_ = w.scheduler.resultUsecase.FailCheckExecution(execution.Id, err.Error())
return
}
// Complete the execution
if err := w.scheduler.resultUsecase.CompleteCheckExecution(execution.Id, result.Id); err != nil {
log.Printf("Worker %d: Error completing execution: %v\n", w.id, err)
return
}
log.Printf("Worker %d: Completed test %s for target %s (status: %d, duration: %v)\n",
w.id, schedule.CheckerName, schedule.TargetId.String(), result.Status, duration)
}
// runCheckSafe calls checker.RunCheck and recovers from panics.
func (w *worker) runCheckSafe(ctx context.Context, checker happydns.Checker, options happydns.CheckerOptions, meta map[string]string) (result *happydns.CheckResult, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("checker panicked: %v", r)
}
}()
return checker.RunCheck(ctx, options, meta)
}

View file

@ -0,0 +1,58 @@
// 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
package app
import (
"fmt"
"git.happydns.org/happyDomain/model"
)
// disabledScheduler is a no-op implementation of SchedulerUsecase used when the
// scheduler is disabled in configuration.
type disabledScheduler struct{}
func (d *disabledScheduler) Run() {}
func (d *disabledScheduler) Close() {}
func (d *disabledScheduler) TriggerOnDemandCheck(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, userId happydns.Identifier, options happydns.CheckerOptions) (happydns.Identifier, error) {
return happydns.Identifier{}, fmt.Errorf("test scheduler is disabled in configuration")
}
// GetSchedulerStatus returns a status indicating the scheduler is disabled
func (d *disabledScheduler) GetSchedulerStatus() happydns.SchedulerStatus {
return happydns.SchedulerStatus{
ConfigEnabled: false,
RuntimeEnabled: false,
Running: false,
}
}
// SetEnabled returns an error since the scheduler is disabled in configuration
func (d *disabledScheduler) SetEnabled(enabled bool) error {
return fmt.Errorf("scheduler is disabled in configuration, cannot enable at runtime")
}
// RescheduleUpcomingChecks returns an error since the scheduler is disabled
func (d *disabledScheduler) RescheduleUpcomingChecks() (int, error) {
return 0, fmt.Errorf("test scheduler is disabled in configuration")
}

134
internal/app/plugins.go Normal file
View file

@ -0,0 +1,134 @@
// 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
package app
import (
"fmt"
"log"
"os"
"path/filepath"
"plugin"
"git.happydns.org/happyDomain/checks"
"git.happydns.org/happyDomain/model"
)
// pluginLoader attempts to find and register one specific kind of plugin
// symbol from an already-opened .so file.
//
// It returns (true, nil) when the symbol was found and registration
// succeeded, (true, err) when the symbol was found but something went wrong,
// and (false, nil) when the symbol simply isn't present in that file (which
// is not considered an error — a single .so may implement only a subset of
// the known plugin types).
type pluginLoader func(p *plugin.Plugin, fname string) (found bool, err error)
// pluginLoaders is the authoritative list of plugin types that happyDomain
// knows about. To support a new plugin type, add a single entry here.
var pluginLoaders = []pluginLoader{
loadCheckPlugin,
}
// loadCheckPlugin handles the NewTestPlugin symbol.
func loadCheckPlugin(p *plugin.Plugin, fname string) (bool, error) {
sym, err := p.Lookup("NewCheckPlugin")
if err != nil {
// Symbol not present in this .so — not an error.
return false, nil
}
factory, ok := sym.(func() (string, happydns.Checker, error))
if !ok {
return true, fmt.Errorf("symbol NewCheckPlugin has unexpected type %T", sym)
}
pluginname, myplugin, err := factory()
if err != nil {
return true, err
}
checks.RegisterChecker(pluginname, myplugin)
log.Printf("Plugin %s loaded", pluginname)
return true, nil
}
// initPlugins scans each directory listed in cfg.PluginsDirectories, loads
// every .so file found as a Go plugin, and registers it in the application's
// PluginManager. All load errors are collected and returned as a joined error
// so that a single bad plugin does not prevent the others from loading.
func (a *App) initPlugins() error {
for _, directory := range a.cfg.PluginsDirectories {
files, err := os.ReadDir(directory)
if err != nil {
return fmt.Errorf("unable to read plugins directory %q: %s", directory, err)
}
for _, file := range files {
if file.IsDir() {
continue
}
// Only attempt to load shared-object files.
if filepath.Ext(file.Name()) != ".so" {
continue
}
fname := filepath.Join(directory, file.Name())
err = loadPlugin(fname)
if err != nil {
log.Printf("Unable to load plugin %q: %s", fname, err)
}
}
}
return nil
}
// loadPlugin opens the .so file at fname and runs every registered
// pluginLoader against it. A loader that does not find its symbol is silently
// skipped. If no loader recognises any symbol in the file a warning is logged,
// but no error is returned because the file might be a valid plugin for a
// future version of happyDomain. The first loader error that is encountered
// is returned immediately.
func loadPlugin(fname string) error {
p, err := plugin.Open(fname)
if err != nil {
return err
}
anyFound := false
for _, loader := range pluginLoaders {
found, err := loader(p, fname)
if err != nil {
return err
}
if found {
anyFound = true
}
}
if !anyFound {
log.Printf("Warning: plugin %q exports no recognised symbols", fname)
}
return nil
}

View file

@ -60,6 +60,8 @@ func declareFlags(o *happydns.Options) {
flag.StringVar(&o.CaptchaProvider, "captcha-provider", o.CaptchaProvider, "Captcha provider to use for bot protection (altcha, hcaptcha, recaptchav2, turnstile, or empty to disable)")
flag.IntVar(&o.CaptchaLoginThreshold, "captcha-login-threshold", 3, "Number of failed login attempts before captcha is required (0 = always require when provider configured)")
flag.Var(&ArrayArgs{&o.PluginsDirectories}, "plugins-directory", "Path to a directory containing plugins (can be repeated multiple times)")
// Others flags are declared in some other files likes sources, storages, ... when they need specials configurations
}

View file

@ -44,15 +44,18 @@ func ConsolidateConfig() (opts *happydns.Options, err error) {
// Define defaults options
opts = &happydns.Options{
AdminBind: "./happydomain.sock",
BasePath: "/",
Bind: ":8081",
DefaultNameServer: "127.0.0.1:53",
ExternalURL: *u,
JWTSigningMethod: "HS512",
MailFrom: mail.Address{Name: "happyDomain", Address: "happydomain@localhost"},
MailSMTPPort: 587,
StorageEngine: "leveldb",
AdminBind: "./happydomain.sock",
BasePath: "/",
Bind: ":8081",
DefaultNameServer: "127.0.0.1:53",
ExternalURL: *u,
JWTSigningMethod: "HS512",
MailFrom: mail.Address{Name: "happyDomain", Address: "happydomain@localhost"},
MailSMTPPort: 587,
StorageEngine: "leveldb",
MaxResultsPerCheck: 100,
ResultRetentionDays: 90,
TestWorkers: 2,
}
declareFlags(opts)

View file

@ -25,8 +25,25 @@ import (
"encoding/base64"
"net/mail"
"net/url"
"strings"
)
type ArrayArgs struct {
Slice *[]string
}
func (i *ArrayArgs) String() string {
if i == nil || i.Slice == nil {
return ""
}
return strings.Join(*i.Slice, ",")
}
func (i *ArrayArgs) Set(value string) error {
*i.Slice = append(*i.Slice, value)
return nil
}
type JWTSecretKey struct {
Secret *[]byte
}

36
internal/mailer/log.go Normal file
View file

@ -0,0 +1,36 @@
// 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
package mailer
import (
"log"
"net/mail"
)
// LogMailer is a dummy mailer that prints emails to stdout.
// It is used when no real mail transport is configured.
type LogMailer struct{}
func (l *LogMailer) SendMail(to *mail.Address, subject, content string) error {
log.Printf("--- Mail to %s ---\nSubject: %s\n\n%s\n--- End of mail ---", to.String(), subject, content)
return nil
}

View file

@ -40,6 +40,7 @@ type InMemoryStorage struct {
data map[string][]byte // Generic key-value store for KVStorage interface
authUsers map[string]*happydns.UserAuth
authUsersByEmail map[string]happydns.Identifier
checksCfg map[string]*happydns.CheckerOptions
domains map[string]*happydns.Domain
domainLogs map[string]*happydns.DomainLogWithDomainId
domainLogsByDomains map[string][]*happydns.Identifier
@ -58,6 +59,7 @@ func NewInMemoryStorage() (*InMemoryStorage, error) {
data: make(map[string][]byte),
authUsers: make(map[string]*happydns.UserAuth),
authUsersByEmail: make(map[string]happydns.Identifier),
checksCfg: make(map[string]*happydns.CheckerOptions),
domains: make(map[string]*happydns.Domain),
domainLogs: make(map[string]*happydns.DomainLogWithDomainId),
domainLogsByDomains: make(map[string][]*happydns.Identifier),

View file

@ -23,6 +23,8 @@ package storage // import "git.happydns.org/happyDomain/internal/storage"
import (
"git.happydns.org/happyDomain/internal/usecase/authuser"
"git.happydns.org/happyDomain/internal/usecase/check"
"git.happydns.org/happyDomain/internal/usecase/checkresult"
"git.happydns.org/happyDomain/internal/usecase/domain"
"git.happydns.org/happyDomain/internal/usecase/domain_log"
"git.happydns.org/happyDomain/internal/usecase/insight"
@ -43,8 +45,10 @@ type Storage interface {
domain.DomainStorage
domainlog.DomainLogStorage
insight.InsightStorage
check.CheckerStorage
provider.ProviderStorage
session.SessionStorage
checkresult.CheckResultStorage
user.UserStorage
zone.ZoneStorage

View file

@ -0,0 +1,185 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
package database
import (
"errors"
"fmt"
"strings"
"git.happydns.org/happyDomain/model"
)
func (s *KVStorage) ListAllCheckerConfigurations() (happydns.Iterator[happydns.CheckerOptions], error) {
iter := s.db.Search("chckrcfg-")
return NewKVIterator[happydns.CheckerOptions](s.db, iter), nil
}
func buildCheckerKey(cname string, user *happydns.Identifier, domain *happydns.Identifier, service *happydns.Identifier) string {
u := ""
if user != nil {
u = user.String()
}
d := ""
if domain != nil {
d = domain.String()
}
s := ""
if service != nil {
s = service.String()
}
return strings.Join([]string{cname, u, d, s}, "/")
}
func keyToPositional(key string, opts *happydns.CheckerOptions) (*happydns.CheckerOptionsPositional, error) {
tmp := strings.Split(key, "/")
if len(tmp) < 4 {
return nil, fmt.Errorf("malformed plugin configuration key, got %q", key)
}
cname := tmp[0]
var userid *happydns.Identifier
if len(tmp[1]) > 0 {
u, err := happydns.NewIdentifierFromString(tmp[1])
if err != nil {
return nil, err
}
userid = &u
}
var domainid *happydns.Identifier
if len(tmp[2]) > 0 {
d, err := happydns.NewIdentifierFromString(tmp[2])
if err != nil {
return nil, err
}
domainid = &d
}
var serviceid *happydns.Identifier
if len(tmp[3]) > 0 {
s, err := happydns.NewIdentifierFromString(tmp[3])
if err != nil {
return nil, err
}
serviceid = &s
}
return &happydns.CheckerOptionsPositional{
CheckName: cname,
UserId: userid,
DomainId: domainid,
ServiceId: serviceid,
Options: *opts,
}, nil
}
func (s *KVStorage) ListCheckerConfiguration(cname string) (configs []*happydns.CheckerOptionsPositional, err error) {
iter := s.db.Search("chckrcfg-" + cname + "/")
defer iter.Release()
for iter.Next() {
var p happydns.CheckerOptions
e := s.db.DecodeData(iter.Value(), &p)
if e != nil {
err = errors.Join(err, e)
continue
}
opts, e := keyToPositional(strings.TrimPrefix(iter.Key(), "chckrcfg-"), &p)
if e != nil {
err = errors.Join(err, e)
continue
}
configs = append(configs, opts)
}
return
}
func (s *KVStorage) GetCheckerConfiguration(cname string, user *happydns.Identifier, domain *happydns.Identifier, service *happydns.Identifier) (configs []*happydns.CheckerOptionsPositional, err error) {
iter := s.db.Search("chckrcfg-" + cname + "/")
defer iter.Release()
for iter.Next() {
var p happydns.CheckerOptions
e := s.db.DecodeData(iter.Value(), &p)
if e != nil {
err = errors.Join(err, e)
continue
}
opts, e := keyToPositional(strings.TrimPrefix(iter.Key(), "chckrcfg-"), &p)
if e != nil {
err = errors.Join(err, e)
continue
}
// Match logic:
// - When parameter is nil: match ONLY configs with nil ID (requesting specific scope)
// - When parameter is not nil: match configs with nil ID (admin-level) OR matching ID
matchUser := (user == nil && opts.UserId == nil) ||
(user != nil && (opts.UserId == nil || opts.UserId.Equals(*user)))
matchDomain := (domain == nil && opts.DomainId == nil) ||
(domain != nil && (opts.DomainId == nil || opts.DomainId.Equals(*domain)))
matchService := (service == nil && opts.ServiceId == nil) ||
(service != nil && (opts.ServiceId == nil || opts.ServiceId.Equals(*service)))
if matchUser && matchDomain && matchService {
configs = append(configs, opts)
}
}
return
}
func (s *KVStorage) UpdateCheckerConfiguration(cname string, user *happydns.Identifier, domain *happydns.Identifier, service *happydns.Identifier, opts happydns.CheckerOptions) error {
return s.db.Put(fmt.Sprintf("chckrcfg-%s", buildCheckerKey(cname, user, domain, service)), opts)
}
func (s *KVStorage) DeleteCheckerConfiguration(cname string, user *happydns.Identifier, domain *happydns.Identifier, service *happydns.Identifier) error {
return s.db.Delete(fmt.Sprintf("chckrcfg-%s", buildCheckerKey(cname, user, domain, service)))
}
func (s *KVStorage) ClearCheckerConfigurations() error {
iter := s.db.Search("chckrcfg-")
defer iter.Release()
for iter.Next() {
err := s.db.Delete(iter.Key())
if err != nil {
return err
}
}
return nil
}

View file

@ -0,0 +1,433 @@
// 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
package database
import (
"errors"
"fmt"
"sort"
"strings"
"time"
"git.happydns.org/happyDomain/model"
)
// Check Result storage keys:
// checkresult|{plugin-name}|{target-type}|{target-id}|{result-id}
func makeCheckResultKey(checkName string, targetType happydns.CheckScopeType, targetId, resultId happydns.Identifier) string {
return fmt.Sprintf("checkresult|%s|%d|%s|%s", checkName, targetType, targetId.String(), resultId.String())
}
func makeCheckResultPrefix(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier) string {
return fmt.Sprintf("checkresult|%s|%d|%s|", checkName, targetType, targetId.String())
}
// ListCheckResults retrieves check results for a specific plugin+target combination
func (s *KVStorage) ListCheckResults(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, limit int) ([]*happydns.CheckResult, error) {
prefix := makeCheckResultPrefix(checkName, targetType, targetId)
iter := s.db.Search(prefix)
defer iter.Release()
var results []*happydns.CheckResult
for iter.Next() {
var r happydns.CheckResult
if err := s.db.DecodeData(iter.Value(), &r); err != nil {
return nil, err
}
results = append(results, &r)
}
// Sort by ExecutedAt descending (most recent first)
sort.Slice(results, func(i, j int) bool {
return results[i].ExecutedAt.After(results[j].ExecutedAt)
})
// Apply limit
if limit > 0 && len(results) > limit {
results = results[:limit]
}
return results, nil
}
// ListCheckResultsByPlugin retrieves all check results for a plugin across all targets for a user
func (s *KVStorage) ListCheckResultsByPlugin(userId happydns.Identifier, checkName string, limit int) ([]*happydns.CheckResult, error) {
prefix := fmt.Sprintf("checkresult|%s|", checkName)
iter := s.db.Search(prefix)
defer iter.Release()
var results []*happydns.CheckResult
for iter.Next() {
var r happydns.CheckResult
if err := s.db.DecodeData(iter.Value(), &r); err != nil {
return nil, err
}
// Filter by user
if r.OwnerId.Equals(userId) {
results = append(results, &r)
}
}
// Sort by ExecutedAt descending (most recent first)
sort.Slice(results, func(i, j int) bool {
return results[i].ExecutedAt.After(results[j].ExecutedAt)
})
// Apply limit
if limit > 0 && len(results) > limit {
results = results[:limit]
}
return results, nil
}
// ListCheckResultsByUser retrieves all check results for a user
func (s *KVStorage) ListCheckResultsByUser(userId happydns.Identifier, limit int) ([]*happydns.CheckResult, error) {
iter := s.db.Search("checkresult|")
defer iter.Release()
var results []*happydns.CheckResult
for iter.Next() {
var r happydns.CheckResult
if err := s.db.DecodeData(iter.Value(), &r); err != nil {
return nil, err
}
// Filter by user
if r.OwnerId.Equals(userId) {
results = append(results, &r)
}
}
// Sort by ExecutedAt descending (most recent first)
sort.Slice(results, func(i, j int) bool {
return results[i].ExecutedAt.After(results[j].ExecutedAt)
})
// Apply limit
if limit > 0 && len(results) > limit {
results = results[:limit]
}
return results, nil
}
// GetCheckResult retrieves a specific check result by its ID
func (s *KVStorage) GetCheckResult(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, resultId happydns.Identifier) (*happydns.CheckResult, error) {
key := makeCheckResultKey(checkName, targetType, targetId, resultId)
var result happydns.CheckResult
err := s.db.Get(key, &result)
if errors.Is(err, happydns.ErrNotFound) {
return nil, happydns.ErrCheckResultNotFound
}
return &result, err
}
// CreateCheckResult stores a new check result
func (s *KVStorage) CreateCheckResult(result *happydns.CheckResult) error {
prefix := makeCheckResultPrefix(result.CheckerName, result.CheckType, result.TargetId)
key, id, err := s.db.FindIdentifierKey(prefix)
if err != nil {
return err
}
result.Id = id
return s.db.Put(key, result)
}
// DeleteCheckResult removes a specific check result
func (s *KVStorage) DeleteCheckResult(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, resultId happydns.Identifier) error {
key := makeCheckResultKey(checkName, targetType, targetId, resultId)
return s.db.Delete(key)
}
// DeleteOldCheckResults removes old check results keeping only the most recent N results
func (s *KVStorage) DeleteOldCheckResults(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, keepCount int) error {
results, err := s.ListCheckResults(checkName, targetType, targetId, 0)
if err != nil {
return err
}
// Results are already sorted by ExecutedAt descending
// Delete results beyond keepCount
if len(results) > keepCount {
for _, r := range results[keepCount:] {
if err := s.DeleteCheckResult(checkName, targetType, targetId, r.Id); err != nil {
return err
}
}
}
return nil
}
// Checker Schedule storage keys:
// checkschedule|{schedule-id}
// checkschedule.byuser|{user-id}|{schedule-id}
// checkschedule.bytarget|{target-type}|{target-id}|{schedule-id}
func makeCheckerScheduleKey(scheduleId happydns.Identifier) string {
return fmt.Sprintf("checkschedule|%s", scheduleId.String())
}
func makeCheckerScheduleUserIndexKey(userId, scheduleId happydns.Identifier) string {
return fmt.Sprintf("checkschedule.byuser|%s|%s", userId.String(), scheduleId.String())
}
func makeCheckerScheduleTargetIndexKey(targetType happydns.CheckScopeType, targetId, scheduleId happydns.Identifier) string {
return fmt.Sprintf("checkschedule.bytarget|%d|%s|%s", targetType, targetId.String(), scheduleId.String())
}
// ListEnabledCheckerSchedules retrieves all enabled schedules
func (s *KVStorage) ListEnabledCheckerSchedules() ([]*happydns.CheckerSchedule, error) {
iter := s.db.Search("checkschedule|")
defer iter.Release()
var schedules []*happydns.CheckerSchedule
for iter.Next() {
var sched happydns.CheckerSchedule
if err := s.db.DecodeData(iter.Value(), &sched); err != nil {
return nil, err
}
if sched.Enabled {
schedules = append(schedules, &sched)
}
}
return schedules, nil
}
// ListCheckerSchedulesByUser retrieves all schedules for a specific user
func (s *KVStorage) ListCheckerSchedulesByUser(userId happydns.Identifier) ([]*happydns.CheckerSchedule, error) {
prefix := fmt.Sprintf("checkschedule.byuser|%s|", userId.String())
iter := s.db.Search(prefix)
defer iter.Release()
var schedules []*happydns.CheckerSchedule
for iter.Next() {
// Extract schedule ID from index key
key := string(iter.Key())
parts := strings.Split(key, "|")
if len(parts) < 3 {
continue
}
scheduleId, err := happydns.NewIdentifierFromString(parts[2])
if err != nil {
continue
}
// Get the actual schedule
var sched happydns.CheckerSchedule
schedKey := makeCheckerScheduleKey(scheduleId)
if err := s.db.Get(schedKey, &sched); err != nil {
continue
}
schedules = append(schedules, &sched)
}
return schedules, nil
}
// ListCheckerSchedulesByTarget retrieves all schedules for a specific target
func (s *KVStorage) ListCheckerSchedulesByTarget(targetType happydns.CheckScopeType, targetId happydns.Identifier) ([]*happydns.CheckerSchedule, error) {
prefix := fmt.Sprintf("checkschedule.bytarget|%d|%s|", targetType, targetId.String())
iter := s.db.Search(prefix)
defer iter.Release()
var schedules []*happydns.CheckerSchedule
for iter.Next() {
// Extract schedule ID from index key
key := string(iter.Key())
parts := strings.Split(key, "|")
if len(parts) < 4 {
continue
}
scheduleId, err := happydns.NewIdentifierFromString(parts[3])
if err != nil {
continue
}
// Get the actual schedule
var sched happydns.CheckerSchedule
schedKey := makeCheckerScheduleKey(scheduleId)
if err := s.db.Get(schedKey, &sched); err != nil {
continue
}
schedules = append(schedules, &sched)
}
return schedules, nil
}
// GetCheckerSchedule retrieves a specific schedule by ID
func (s *KVStorage) GetCheckerSchedule(scheduleId happydns.Identifier) (*happydns.CheckerSchedule, error) {
key := makeCheckerScheduleKey(scheduleId)
var schedule happydns.CheckerSchedule
err := s.db.Get(key, &schedule)
if errors.Is(err, happydns.ErrNotFound) {
return nil, happydns.ErrCheckScheduleNotFound
}
return &schedule, err
}
// CreateCheckerSchedule creates a new check schedule
func (s *KVStorage) CreateCheckerSchedule(schedule *happydns.CheckerSchedule) error {
key, id, err := s.db.FindIdentifierKey("checkschedule|")
if err != nil {
return err
}
schedule.Id = id
// Store the schedule
if err := s.db.Put(key, schedule); err != nil {
return err
}
// Create indexes
userIndexKey := makeCheckerScheduleUserIndexKey(schedule.OwnerId, schedule.Id)
if err := s.db.Put(userIndexKey, []byte{}); err != nil {
return err
}
targetIndexKey := makeCheckerScheduleTargetIndexKey(schedule.TargetType, schedule.TargetId, schedule.Id)
if err := s.db.Put(targetIndexKey, []byte{}); err != nil {
return err
}
return nil
}
// UpdateCheckerSchedule updates an existing schedule
func (s *KVStorage) UpdateCheckerSchedule(schedule *happydns.CheckerSchedule) error {
key := makeCheckerScheduleKey(schedule.Id)
return s.db.Put(key, schedule)
}
// DeleteCheckerSchedule removes a schedule and its indexes
func (s *KVStorage) DeleteCheckerSchedule(scheduleId happydns.Identifier) error {
// Get the schedule first to know what indexes to delete
schedule, err := s.GetCheckerSchedule(scheduleId)
if err != nil {
return err
}
// Delete indexes
userIndexKey := makeCheckerScheduleUserIndexKey(schedule.OwnerId, schedule.Id)
if err := s.db.Delete(userIndexKey); err != nil {
return err
}
targetIndexKey := makeCheckerScheduleTargetIndexKey(schedule.TargetType, schedule.TargetId, schedule.Id)
if err := s.db.Delete(targetIndexKey); err != nil {
return err
}
// Delete the schedule itself
key := makeCheckerScheduleKey(scheduleId)
return s.db.Delete(key)
}
// Check Execution storage keys:
// checkexec|{execution-id}
func makeCheckExecutionKey(executionId happydns.Identifier) string {
return fmt.Sprintf("checkexec|%s", executionId.String())
}
// ListActiveCheckExecutions retrieves all executions that are pending or running
func (s *KVStorage) ListActiveCheckExecutions() ([]*happydns.CheckExecution, error) {
iter := s.db.Search("checkexec|")
defer iter.Release()
var executions []*happydns.CheckExecution
for iter.Next() {
var exec happydns.CheckExecution
if err := s.db.DecodeData(iter.Value(), &exec); err != nil {
return nil, err
}
if exec.Status == happydns.CheckExecutionPending || exec.Status == happydns.CheckExecutionRunning {
executions = append(executions, &exec)
}
}
return executions, nil
}
// GetCheckExecution retrieves a specific execution by ID
func (s *KVStorage) GetCheckExecution(executionId happydns.Identifier) (*happydns.CheckExecution, error) {
key := makeCheckExecutionKey(executionId)
var execution happydns.CheckExecution
err := s.db.Get(key, &execution)
if errors.Is(err, happydns.ErrNotFound) {
return nil, happydns.ErrCheckExecutionNotFound
}
return &execution, err
}
// CreateCheckExecution creates a new check execution record
func (s *KVStorage) CreateCheckExecution(execution *happydns.CheckExecution) error {
key, id, err := s.db.FindIdentifierKey("checkexec|")
if err != nil {
return err
}
execution.Id = id
return s.db.Put(key, execution)
}
// UpdateCheckExecution updates an existing execution record
func (s *KVStorage) UpdateCheckExecution(execution *happydns.CheckExecution) error {
key := makeCheckExecutionKey(execution.Id)
return s.db.Put(key, execution)
}
// DeleteCheckExecution removes an execution record
func (s *KVStorage) DeleteCheckExecution(executionId happydns.Identifier) error {
key := makeCheckExecutionKey(executionId)
return s.db.Delete(key)
}
// Scheduler state storage key:
// checkscheduler.lastrun
// CheckerSchedulerRun marks that the scheduler has run at current time
func (s *KVStorage) CheckSchedulerRun() error {
now := time.Now()
return s.db.Put("checkscheduler.lastrun", &now)
}
// LastCheckSchedulerRun retrieves the last time the scheduler ran
func (s *KVStorage) LastCheckSchedulerRun() (*time.Time, error) {
var lastRun time.Time
err := s.db.Get("checkscheduler.lastrun", &lastRun)
if errors.Is(err, happydns.ErrNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &lastRun, nil
}

View file

@ -23,10 +23,11 @@ package authuser
import (
"fmt"
"reflect"
"regexp"
"strings"
"log"
"net/mail"
"unicode"
"git.happydns.org/happyDomain/internal/helpers"
"git.happydns.org/happyDomain/model"
)
@ -93,13 +94,27 @@ func (s *Service) checkPasswordConstraints(password, confirmation string) error
return happydns.ValidationError{Msg: "password must be at most 72 characters long"}
}
if !regexp.MustCompile(`[a-z]`).MatchString(password) {
var hasLower, hasUpper, hasDigit, hasSymbol bool
for _, r := range password {
switch {
case unicode.IsLower(r):
hasLower = true
case unicode.IsUpper(r):
hasUpper = true
case unicode.IsDigit(r):
hasDigit = true
default:
hasSymbol = true
}
}
if !hasLower {
return happydns.ValidationError{Msg: "Password must contain lower case letters."}
} else if !regexp.MustCompile(`[A-Z]`).MatchString(password) {
} else if !hasUpper {
return happydns.ValidationError{Msg: "Password must contain upper case letters."}
} else if !regexp.MustCompile(`[0-9]`).MatchString(password) {
} else if !hasDigit {
return happydns.ValidationError{Msg: "Password must contain numbers."}
} else if len(password) < 11 && !regexp.MustCompile(`[^a-zA-Z0-9]`).MatchString(password) {
} else if len(password) < 11 && !hasSymbol {
return happydns.ValidationError{Msg: "Password must be longer or contain symbols."}
}
@ -131,9 +146,11 @@ func (s *Service) ChangePassword(user *happydns.UserAuth, newPassword string) er
}
// CreateAuthUser validates the registration request, creates the user, and optionally sends a validation email.
// To prevent user enumeration, this method returns nil user with nil error when an account
// already exists with the given email address, after sending a notification to the existing user.
func (s *Service) CreateAuthUser(uu happydns.UserRegistration) (*happydns.UserAuth, error) {
// Validate email format
if len(uu.Email) <= 3 || !strings.Contains(uu.Email, "@") {
if _, err := mail.ParseAddress(uu.Email); err != nil {
return nil, happydns.ValidationError{Msg: "the given email is invalid"}
}
@ -152,7 +169,9 @@ func (s *Service) CreateAuthUser(uu happydns.UserRegistration) (*happydns.UserAu
}
}
if exists {
return nil, happydns.ValidationError{Msg: "an account already exists with the given address. Try logging in."}
// Send a notification to the existing user (best effort) to avoid user enumeration.
s.sendDuplicateRegistrationNotice(uu.Email)
return nil, nil
}
// Create the user object
@ -173,19 +192,40 @@ func (s *Service) CreateAuthUser(uu happydns.UserRegistration) (*happydns.UserAu
}
}
// Optionally send the validation email if mailer is configured
if s.mailer != nil && !reflect.ValueOf(s.mailer).IsNil() {
if err = s.emailValidation.SendLink(user); err != nil {
return nil, happydns.InternalError{
Err: fmt.Errorf("unable to send validation email: %w", err),
UserMessage: "Sorry, we are currently unable to create your account. Please try again later.",
}
// Send the validation email
if err = s.emailValidation.SendLink(user); err != nil {
return nil, happydns.InternalError{
Err: fmt.Errorf("unable to send validation email: %w", err),
UserMessage: "Sorry, we are currently unable to create your account. Please try again later.",
}
}
return user, nil
}
// sendDuplicateRegistrationNotice sends an email to an existing user when someone
// attempts to register with their email address.
func (s *Service) sendDuplicateRegistrationNotice(email string) {
toName := helpers.GenUsername(email)
err := s.mailer.SendMail(
&mail.Address{Name: toName, Address: email},
"Registration attempt on happyDomain",
fmt.Sprintf(`Hi %s,
Someone (possibly you) attempted to create a new account on happyDomain
using your email address.
If this was you, you already have an account. You can log in or use the
password recovery feature if you have forgotten your password.
If this was not you, you can safely ignore this email.
`, toName),
)
if err != nil {
log.Printf("unable to send duplicate registration notice to %s: %v", email, err)
}
}
// DeleteAuthUser deletes an authenticated user from the system, ensuring their sessions are also removed.
func (s *Service) DeleteAuthUser(user *happydns.UserAuth, password string) error {
// Verify the current password
@ -235,7 +275,7 @@ func (s *Service) SendRecoveryLink(user *happydns.UserAuth) error {
}
// GenerateValidationLink generates an email validation link for the given user.
func (s *Service) GenerateValidationLink(user *happydns.UserAuth) string {
func (s *Service) GenerateValidationLink(user *happydns.UserAuth) (string, error) {
return s.emailValidation.GenerateLink(user)
}

View file

@ -22,7 +22,10 @@
package authuser_test
import (
"errors"
"fmt"
"net/mail"
"strings"
"testing"
"time"
@ -33,6 +36,13 @@ import (
"git.happydns.org/happyDomain/model"
)
// NoopMailer is a mock mailer that discards all emails.
type NoopMailer struct{}
func (n *NoopMailer) SendMail(to *mail.Address, subject, content string) error {
return nil
}
// MockCloseUserSessionsUsecase is a mock implementation of SessionCloserUsecase.
type MockCloseUserSessionsUsecase struct {
CloseAllFunc func(user happydns.UserInfo) error
@ -56,11 +66,21 @@ func setupTestService() (*authuser.Service, storage.Storage) {
DisableRegistration: false,
}
mockCloseSessions := &MockCloseUserSessionsUsecase{}
// Pass nil mailer to avoid sending emails in tests
service := authuser.NewAuthUserUsecases(cfg, nil, store, mockCloseSessions)
service := authuser.NewAuthUserUsecases(cfg, &NoopMailer{}, store, mockCloseSessions)
return service, store
}
func requireValidationError(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatal("expected an error, got nil")
}
var ve happydns.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected ValidationError, got %T: %v", err, err)
}
}
// ========== CanRegister Tests ==========
func TestCanRegister_Success(t *testing.T) {
@ -81,10 +101,10 @@ func TestCanRegister_Closed(t *testing.T) {
mem, _ := inmemory.NewInMemoryStorage()
store, _ := kv.NewKVDatabase(mem)
cfg := &happydns.Options{
DisableRegistration: true, // Registration closed
DisableRegistration: true,
}
mockCloseSessions := &MockCloseUserSessionsUsecase{}
service := authuser.NewAuthUserUsecases(cfg, nil, store, mockCloseSessions)
service := authuser.NewAuthUserUsecases(cfg, &NoopMailer{}, store, mockCloseSessions)
reg := happydns.UserRegistration{
Email: "test@example.com",
@ -92,8 +112,8 @@ func TestCanRegister_Closed(t *testing.T) {
}
err := service.CanRegister(reg)
if err == nil || err.Error() != "Registration are closed on this instance." {
t.Errorf("expected registration closed error, got: %v", err)
if err == nil {
t.Error("expected registration closed error, got nil")
}
}
@ -117,7 +137,7 @@ func TestCreateAuthUser_Success(t *testing.T) {
t.Errorf("expected email %s, got %s", reg.Email, user.Email)
}
if user.Password == nil {
t.Errorf("expected defined password, got %s", user.Password)
t.Error("expected defined password")
}
if !user.AllowCommercials {
t.Error("expected user to have AllowCommercials = true")
@ -127,53 +147,71 @@ func TestCreateAuthUser_Success(t *testing.T) {
func TestCreateAuthUser_InvalidEmail(t *testing.T) {
service, _ := setupTestService()
reg := happydns.UserRegistration{
Email: "bademail",
Password: "StrongPassword123!",
}
_, err := service.CreateAuthUser(reg)
if err == nil || err.Error() != "the given email is invalid" {
t.Errorf("expected validation error for email, got: %v", err)
cases := []string{"", "ab", "bademail", "a@"}
for _, email := range cases {
t.Run(email, func(t *testing.T) {
reg := happydns.UserRegistration{
Email: email,
Password: "StrongPassword123!",
}
_, err := service.CreateAuthUser(reg)
requireValidationError(t, err)
})
}
}
func TestCreateAuthUser_WeakPassword(t *testing.T) {
service, _ := setupTestService()
cases := []struct {
name string
password string
}{
{"too short", "123"},
{"short with symbols", "Secur3$"},
{"no uppercase", "secure123"},
{"short without symbols", "Secure123"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
reg := happydns.UserRegistration{
Email: "test@example.com",
Password: tc.password,
}
_, err := service.CreateAuthUser(reg)
requireValidationError(t, err)
})
}
}
func TestCreateAuthUser_PasswordMaxLength(t *testing.T) {
service, _ := setupTestService()
// Exactly 72 characters should be accepted (bcrypt limit)
pw72 := "Abcdefg1!" + strings.Repeat("x", 63) // 9 + 63 = 72
reg := happydns.UserRegistration{
Email: "test@example.com",
Password: "123",
Email: "max72@example.com",
Password: pw72,
}
_, err := service.CreateAuthUser(reg)
if err == nil || err.Error() != "password must be at least 8 characters long" {
t.Errorf("expected password constraint error, got: %v", err)
if err != nil {
t.Fatalf("expected 72-char password to be accepted, got %v", err)
}
reg.Password = "Secur3$"
_, err = service.CreateAuthUser(reg)
if err == nil || err.Error() != "password must be at least 8 characters long" {
t.Errorf("expected password constraint error, got: %v", err)
// 73 characters should be rejected
pw73 := pw72 + "x"
reg = happydns.UserRegistration{
Email: "max73@example.com",
Password: pw73,
}
reg.Password = "secure123"
_, err = service.CreateAuthUser(reg)
if err == nil || err.Error() != "Password must contain upper case letters." {
t.Errorf("expected password constraint error, got: %v", err)
}
reg.Password = "Secure123"
_, err = service.CreateAuthUser(reg)
if err == nil || err.Error() != "Password must be longer or contain symbols." {
t.Errorf("expected password constraint error, got: %v", err)
}
requireValidationError(t, err)
}
func TestCreateAuthUser_EmailAlreadyUsed(t *testing.T) {
service, _ := setupTestService()
// Create a user first
reg := happydns.UserRegistration{
Email: "used@example.com",
Password: "StrongPassword123!",
@ -183,10 +221,14 @@ func TestCreateAuthUser_EmailAlreadyUsed(t *testing.T) {
t.Fatalf("setup user creation failed: %v", err)
}
// Try creating again with the same email
_, err = service.CreateAuthUser(reg)
if err == nil || err.Error() != "an account already exists with the given address. Try logging in." {
t.Errorf("expected duplicate email error, got: %v", err)
// Try creating again with the same email.
// The implementation silently succeeds (returns nil, nil) to prevent user enumeration.
user, err := service.CreateAuthUser(reg)
if err != nil {
t.Errorf("expected no error for duplicate email (anti-enumeration), got: %v", err)
}
if user != nil {
t.Errorf("expected nil user for duplicate email, got non-nil")
}
}
@ -212,7 +254,7 @@ func TestGetAuthUser(t *testing.T) {
t.Fatalf("Expected non-nil user ID, got %s", user.Id)
}
t.Run("GetAuthUser returns the correct user", func(t *testing.T) {
t.Run("returns the correct user", func(t *testing.T) {
got, err := service.GetAuthUser(user.Id)
if err != nil {
t.Errorf("Expected no error, got %v", err)
@ -222,7 +264,7 @@ func TestGetAuthUser(t *testing.T) {
}
})
t.Run("GetAuthUserByEmail returns the correct user", func(t *testing.T) {
t.Run("by email returns the correct user", func(t *testing.T) {
got, err := service.GetAuthUserByEmail("test@example.com")
if err != nil {
t.Errorf("Expected no error, got %v", err)
@ -232,14 +274,14 @@ func TestGetAuthUser(t *testing.T) {
}
})
t.Run("GetAuthUser returns error for unknown ID", func(t *testing.T) {
t.Run("returns error for unknown ID", func(t *testing.T) {
_, err := service.GetAuthUser([]byte("unknown-id"))
if err == nil {
t.Error("Expected error for unknown ID, got nil")
}
})
t.Run("GetAuthUserByEmail returns error for unknown email", func(t *testing.T) {
t.Run("returns error for unknown email", func(t *testing.T) {
_, err := service.GetAuthUserByEmail("unknown@example.com")
if err == nil {
t.Error("Expected error for unknown email, got nil")
@ -277,6 +319,24 @@ func TestChangePassword(t *testing.T) {
}
}
func TestChangePassword_WeakNewPassword(t *testing.T) {
service, store := setupTestService()
user := &happydns.UserAuth{
Email: "test@example.com",
}
user.DefinePassword("OldPassword123!")
store.CreateAuthUser(user)
err := service.ChangePassword(user, "short")
requireValidationError(t, err)
// Verify old password still works (change was not applied)
if !user.CheckPassword("OldPassword123!") {
t.Error("expected old password to still be valid after failed change")
}
}
func TestCheckPassword(t *testing.T) {
service, store := setupTestService()
@ -290,7 +350,7 @@ func TestCheckPassword(t *testing.T) {
t.Fatalf("expected no error, got %v", err)
}
t.Run("CheckPassword with correct current password", func(t *testing.T) {
t.Run("correct current password", func(t *testing.T) {
form := happydns.ChangePasswordForm{
Current: "OldPassword123!",
Password: "NewPa$$w0rd",
@ -302,7 +362,7 @@ func TestCheckPassword(t *testing.T) {
}
})
t.Run("CheckPassword with incorrect current password", func(t *testing.T) {
t.Run("incorrect current password", func(t *testing.T) {
form := happydns.ChangePasswordForm{
Current: "WrongPassword123!",
Password: "NewPa$$w0rd",
@ -313,6 +373,16 @@ func TestCheckPassword(t *testing.T) {
t.Error("Expected error for incorrect current password")
}
})
t.Run("correct current but weak new password", func(t *testing.T) {
form := happydns.ChangePasswordForm{
Current: "OldPassword123!",
Password: "weak",
PasswordConfirm: "weak",
}
err := service.CheckPassword(user, form)
requireValidationError(t, err)
})
}
func TestCheckNewPassword(t *testing.T) {
@ -322,7 +392,7 @@ func TestCheckNewPassword(t *testing.T) {
Email: "test@example.com",
}
t.Run("CheckNewPassword with matching passwords", func(t *testing.T) {
t.Run("matching passwords", func(t *testing.T) {
form := happydns.ChangePasswordForm{
Password: "NewPa$$w0rd",
PasswordConfirm: "NewPa$$w0rd",
@ -333,14 +403,23 @@ func TestCheckNewPassword(t *testing.T) {
}
})
t.Run("CheckNewPassword with non-matching passwords", func(t *testing.T) {
t.Run("non-matching passwords", func(t *testing.T) {
form := happydns.ChangePasswordForm{
Password: "NewPa$$w0rd",
PasswordConfirm: "DifferentPassword123!",
}
err := service.CheckNewPassword(user, form)
if err == nil {
t.Error("Expected error for non-matching passwords")
requireValidationError(t, err)
})
t.Run("empty confirmation is accepted", func(t *testing.T) {
form := happydns.ChangePasswordForm{
Password: "NewPa$$w0rd",
PasswordConfirm: "",
}
err := service.CheckNewPassword(user, form)
if err != nil {
t.Fatalf("Expected empty confirmation to be accepted, got %v", err)
}
})
}
@ -358,7 +437,7 @@ func TestDeleteAuthUser(t *testing.T) {
return nil
},
}
service := authuser.NewAuthUserUsecases(cfg, nil, store, mockCloseSessions)
service := authuser.NewAuthUserUsecases(cfg, &NoopMailer{}, store, mockCloseSessions)
user := &happydns.UserAuth{
Email: "test@example.com",
@ -370,24 +449,24 @@ func TestDeleteAuthUser(t *testing.T) {
t.Fatalf("Expected no error, got %v", err)
}
t.Run("DeleteAuthUser with invalid password", func(t *testing.T) {
t.Run("invalid password", func(t *testing.T) {
err := service.DeleteAuthUser(user, "WrongPassword")
if err == nil || err.Error() != "invalid current password" {
t.Errorf("Expected error 'invalid current password', got %v", err)
if err == nil {
t.Error("expected error for invalid password")
}
})
t.Run("DeleteAuthUser with error in closing sessions", func(t *testing.T) {
t.Run("error in closing sessions", func(t *testing.T) {
mockCloseSessions.CloseAllFunc = func(user happydns.UserInfo) error {
return fmt.Errorf("error closing sessions")
}
err := service.DeleteAuthUser(user, "TestPassword123!")
if err == nil || err.Error() != "unable to delete user sessions: error closing sessions" {
t.Errorf("Expected error 'unable to delete user sessions: error closing sessions', got %v", err)
if err == nil {
t.Error("expected error when session close fails")
}
})
t.Run("DeleteAuthUser successful deletion", func(t *testing.T) {
t.Run("successful deletion", func(t *testing.T) {
mockCloseSessions.CloseAllFunc = func(user happydns.UserInfo) error {
return nil
}
@ -395,5 +474,500 @@ func TestDeleteAuthUser(t *testing.T) {
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
// Verify user is gone
_, err = store.GetAuthUser(user.Id)
if err == nil {
t.Error("expected error when fetching deleted user")
}
})
}
// ========== GenRegistrationHash Tests ==========
func TestGenRegistrationHash_Deterministic(t *testing.T) {
createdAt := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)
key := []byte("test-recovery-key-for-registration-hash-0123456")
hash1 := authuser.GenRegistrationHash(createdAt, key, false)
hash2 := authuser.GenRegistrationHash(createdAt, key, false)
if hash1 == "" {
t.Fatal("expected non-empty hash")
}
if hash1 != hash2 {
t.Error("expected identical hashes for same input and time period")
}
}
func TestGenRegistrationHash_EmptyKey(t *testing.T) {
createdAt := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)
hash := authuser.GenRegistrationHash(createdAt, nil, false)
if hash != "" {
t.Errorf("expected empty hash for nil key, got %q", hash)
}
hash = authuser.GenRegistrationHash(createdAt, []byte{}, false)
if hash != "" {
t.Errorf("expected empty hash for empty key, got %q", hash)
}
}
func TestGenRegistrationHash_DifferentPeriods(t *testing.T) {
createdAt := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)
key := []byte("test-recovery-key-for-registration-hash-0123456")
current := authuser.GenRegistrationHash(createdAt, key, false)
previous := authuser.GenRegistrationHash(createdAt, key, true)
if current == "" || previous == "" {
t.Error("expected non-empty hashes for both periods")
}
}
func TestGenRegistrationHash_DifferentCreatedAt(t *testing.T) {
key := []byte("shared-key-for-different-createdat-test-1234567")
createdAt1 := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)
createdAt2 := time.Date(2025, 6, 20, 14, 0, 0, 0, time.UTC)
hash1 := authuser.GenRegistrationHash(createdAt1, key, false)
hash2 := authuser.GenRegistrationHash(createdAt2, key, false)
if hash1 == hash2 {
t.Error("expected different hashes for different CreatedAt")
}
}
func TestGenRegistrationHash_DifferentKeys(t *testing.T) {
createdAt := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)
key1 := []byte("key-one-for-registration-hash-different-keys-test")
key2 := []byte("key-two-for-registration-hash-different-keys-test")
hash1 := authuser.GenRegistrationHash(createdAt, key1, false)
hash2 := authuser.GenRegistrationHash(createdAt, key2, false)
if hash1 == hash2 {
t.Error("expected different hashes for different keys")
}
}
// ========== GenAccountRecoveryHash Tests ==========
func TestGenAccountRecoveryHash_Deterministic(t *testing.T) {
key := []byte("some-secret-recovery-key-for-testing-1234567890")
hash1 := authuser.GenAccountRecoveryHash(key, false)
hash2 := authuser.GenAccountRecoveryHash(key, false)
if hash1 == "" {
t.Fatal("expected non-empty hash")
}
if hash1 != hash2 {
t.Error("expected identical hashes for same key and time period")
}
}
func TestGenAccountRecoveryHash_EmptyKey(t *testing.T) {
hash := authuser.GenAccountRecoveryHash(nil, false)
if hash != "" {
t.Errorf("expected empty hash for nil key, got %q", hash)
}
hash = authuser.GenAccountRecoveryHash([]byte{}, false)
if hash != "" {
t.Errorf("expected empty hash for empty key, got %q", hash)
}
}
func TestGenAccountRecoveryHash_DifferentKeys(t *testing.T) {
key1 := []byte("key-one-for-testing-recovery-hash-generation")
key2 := []byte("key-two-for-testing-recovery-hash-generation")
hash1 := authuser.GenAccountRecoveryHash(key1, false)
hash2 := authuser.GenAccountRecoveryHash(key2, false)
if hash1 == hash2 {
t.Error("expected different hashes for different keys")
}
}
// ========== CanRecoverAccount Tests ==========
func TestCanRecoverAccount_ValidKey(t *testing.T) {
key := []byte("recovery-key-for-can-recover-test-1234567890ab")
user := &happydns.UserAuth{
Email: "test@example.com",
PasswordRecoveryKey: key,
}
validHash := authuser.GenAccountRecoveryHash(key, false)
err := authuser.CanRecoverAccount(user, validHash)
if err != nil {
t.Fatalf("expected valid key to be accepted, got %v", err)
}
}
func TestCanRecoverAccount_PreviousPeriodKey(t *testing.T) {
key := []byte("recovery-key-for-previous-period-test-12345678")
user := &happydns.UserAuth{
Email: "test@example.com",
PasswordRecoveryKey: key,
}
previousHash := authuser.GenAccountRecoveryHash(key, true)
err := authuser.CanRecoverAccount(user, previousHash)
if err != nil {
t.Fatalf("expected previous-period key to be accepted, got %v", err)
}
}
func TestCanRecoverAccount_InvalidKey(t *testing.T) {
key := []byte("recovery-key-for-invalid-key-test-1234567890ab")
user := &happydns.UserAuth{
Email: "test@example.com",
PasswordRecoveryKey: key,
}
err := authuser.CanRecoverAccount(user, "totally-invalid-key")
if err == nil {
t.Error("expected error for invalid recovery key")
}
}
func TestCanRecoverAccount_NilRecoveryKey(t *testing.T) {
user := &happydns.UserAuth{
Email: "test@example.com",
PasswordRecoveryKey: nil,
}
err := authuser.CanRecoverAccount(user, "any-key")
if err == nil {
t.Error("expected error when user has no recovery key")
}
}
// ========== Email Validation Flow Tests ==========
func TestEmailValidation_GenerateLink(t *testing.T) {
service, _ := setupTestService()
user, err := service.CreateAuthUser(happydns.UserRegistration{
Email: "validate@example.com",
Password: "StrongPassword123!",
})
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
link, err := service.GenerateValidationLink(user)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if link == "" {
t.Fatal("expected non-empty validation link")
}
if !strings.Contains(link, "/email-validation") {
t.Errorf("expected link to contain /email-validation, got %s", link)
}
if !strings.Contains(link, "u=") || !strings.Contains(link, "k=") {
t.Errorf("expected link to contain u= and k= parameters, got %s", link)
}
}
func TestEmailValidation_ValidateSuccess(t *testing.T) {
service, _ := setupTestService()
user, err := service.CreateAuthUser(happydns.UserRegistration{
Email: "validate@example.com",
Password: "StrongPassword123!",
})
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
if user.EmailVerification != nil {
t.Fatal("expected EmailVerification to be nil before validation")
}
// Ensure recovery key exists (GenerateValidationLink generates it as side effect)
_, err = service.GenerateValidationLink(user)
if err != nil {
t.Fatalf("failed to generate validation link: %v", err)
}
key := authuser.GenRegistrationHash(user.CreatedAt, user.PasswordRecoveryKey, false)
err = service.ValidateEmail(user, happydns.AddressValidationForm{Key: key})
if err != nil {
t.Fatalf("expected validation to succeed, got %v", err)
}
if user.EmailVerification == nil {
t.Error("expected EmailVerification to be set after validation")
}
}
func TestEmailValidation_ValidateWithPreviousPeriodKey(t *testing.T) {
service, _ := setupTestService()
user, err := service.CreateAuthUser(happydns.UserRegistration{
Email: "validate-prev@example.com",
Password: "StrongPassword123!",
})
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, err = service.GenerateValidationLink(user)
if err != nil {
t.Fatalf("failed to generate validation link: %v", err)
}
key := authuser.GenRegistrationHash(user.CreatedAt, user.PasswordRecoveryKey, true)
err = service.ValidateEmail(user, happydns.AddressValidationForm{Key: key})
if err != nil {
t.Fatalf("expected previous-period key to be accepted, got %v", err)
}
}
func TestEmailValidation_ValidateInvalidKey(t *testing.T) {
service, _ := setupTestService()
user, err := service.CreateAuthUser(happydns.UserRegistration{
Email: "validate-bad@example.com",
Password: "StrongPassword123!",
})
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
err = service.ValidateEmail(user, happydns.AddressValidationForm{Key: "invalid-key"})
requireValidationError(t, err)
if user.EmailVerification != nil {
t.Error("expected EmailVerification to remain nil after failed validation")
}
}
// ========== Recovery Flow Tests ==========
func TestRecovery_GenerateLink(t *testing.T) {
service, _ := setupTestService()
user, err := service.CreateAuthUser(happydns.UserRegistration{
Email: "recover@example.com",
Password: "StrongPassword123!",
})
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
link, err := service.GenerateRecoveryLink(user)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if link == "" {
t.Fatal("expected non-empty recovery link")
}
if !strings.Contains(link, "/forgotten-password") {
t.Errorf("expected link to contain /forgotten-password, got %s", link)
}
if !strings.Contains(link, "u=") || !strings.Contains(link, "k=") {
t.Errorf("expected link to contain u= and k= parameters, got %s", link)
}
if user.PasswordRecoveryKey == nil {
t.Error("expected PasswordRecoveryKey to be set after generating link")
}
}
func TestRecovery_GenerateLinkIdempotent(t *testing.T) {
service, _ := setupTestService()
user, err := service.CreateAuthUser(happydns.UserRegistration{
Email: "recover-idem@example.com",
Password: "StrongPassword123!",
})
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
link1, err := service.GenerateRecoveryLink(user)
if err != nil {
t.Fatalf("expected no error on first call, got %v", err)
}
link2, err := service.GenerateRecoveryLink(user)
if err != nil {
t.Fatalf("expected no error on second call, got %v", err)
}
if link1 != link2 {
t.Error("expected same link for repeated calls (key already exists)")
}
}
func TestRecovery_ResetPasswordSuccess(t *testing.T) {
service, _ := setupTestService()
user, err := service.CreateAuthUser(happydns.UserRegistration{
Email: "reset@example.com",
Password: "OldPassword123!",
})
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, err = service.GenerateRecoveryLink(user)
if err != nil {
t.Fatalf("failed to generate recovery link: %v", err)
}
key := authuser.GenAccountRecoveryHash(user.PasswordRecoveryKey, false)
newPassword := "NewPa$$w0rd99"
err = service.ResetPassword(user, happydns.AccountRecoveryForm{
Key: key,
Password: newPassword,
})
if err != nil {
t.Fatalf("expected password reset to succeed, got %v", err)
}
if !user.CheckPassword(newPassword) {
t.Error("expected new password to work after reset")
}
}
func TestRecovery_ResetPasswordInvalidKey(t *testing.T) {
service, _ := setupTestService()
user, err := service.CreateAuthUser(happydns.UserRegistration{
Email: "reset-bad@example.com",
Password: "OldPassword123!",
})
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, err = service.GenerateRecoveryLink(user)
if err != nil {
t.Fatalf("failed to generate recovery link: %v", err)
}
err = service.ResetPassword(user, happydns.AccountRecoveryForm{
Key: "invalid-key",
Password: "NewPa$$w0rd99",
})
if err == nil {
t.Error("expected error for invalid recovery key")
}
if !user.CheckPassword("OldPassword123!") {
t.Error("expected old password to still work after failed reset")
}
}
func TestRecovery_ResetPasswordWeakNewPassword(t *testing.T) {
service, _ := setupTestService()
user, err := service.CreateAuthUser(happydns.UserRegistration{
Email: "reset-weak@example.com",
Password: "OldPassword123!",
})
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, err = service.GenerateRecoveryLink(user)
if err != nil {
t.Fatalf("failed to generate recovery link: %v", err)
}
key := authuser.GenAccountRecoveryHash(user.PasswordRecoveryKey, false)
err = service.ResetPassword(user, happydns.AccountRecoveryForm{
Key: key,
Password: "weak",
})
requireValidationError(t, err)
}
func TestRecovery_ResetPasswordInvalidatesKey(t *testing.T) {
service, _ := setupTestService()
user, err := service.CreateAuthUser(happydns.UserRegistration{
Email: "reset-invalidate@example.com",
Password: "OldPassword123!",
})
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, err = service.GenerateRecoveryLink(user)
if err != nil {
t.Fatalf("failed to generate recovery link: %v", err)
}
key := authuser.GenAccountRecoveryHash(user.PasswordRecoveryKey, false)
err = service.ResetPassword(user, happydns.AccountRecoveryForm{
Key: key,
Password: "NewPa$$w0rd99",
})
if err != nil {
t.Fatalf("expected first reset to succeed, got %v", err)
}
// DefinePassword clears PasswordRecoveryKey, so the same key should no longer work
if user.PasswordRecoveryKey != nil {
t.Error("expected PasswordRecoveryKey to be nil after password reset")
}
err = authuser.CanRecoverAccount(user, key)
if err == nil {
t.Error("expected recovery key to be invalidated after successful reset")
}
}
// ========== SendRecoveryLink Tests ==========
func TestSendRecoveryLink(t *testing.T) {
service, _ := setupTestService()
user, err := service.CreateAuthUser(happydns.UserRegistration{
Email: "send-recover@example.com",
Password: "StrongPassword123!",
})
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
err = service.SendRecoveryLink(user)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if user.PasswordRecoveryKey == nil {
t.Error("expected PasswordRecoveryKey to be set after sending recovery link")
}
}
// ========== SendValidationLink Tests ==========
func TestSendValidationLink(t *testing.T) {
service, _ := setupTestService()
user, err := service.CreateAuthUser(happydns.UserRegistration{
Email: "send-validate@example.com",
Password: "StrongPassword123!",
})
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
err = service.SendValidationLink(user)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
}

View file

@ -0,0 +1,27 @@
// 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
// Package authuser groups all use cases related to authenticated user
// management: registration, password changes, email validation, account
// recovery, and deletion. The Service type is the main entry point; it
// composes EmailValidationUsecase and RecoverAccountUsecase for their
// respective sub-workflows.
package authuser

View file

@ -27,9 +27,7 @@ import (
"crypto/sha512"
"encoding/base64"
"fmt"
"log"
"net/mail"
"reflect"
"time"
"git.happydns.org/happyDomain/internal/helpers"
@ -111,11 +109,6 @@ func (uc *RecoverAccountUsecase) SendLink(user *happydns.UserAuth) error {
toName := helpers.GenUsername(user.Email)
if uc.mailer == nil || reflect.ValueOf(uc.mailer).IsNil() {
log.Printf("No mailer configured. Recovery link for %s: %s", user.Email, link)
return nil
}
return uc.mailer.SendMail(
&mail.Address{Name: toName, Address: user.Email},
"Recover your happyDomain account",

View file

@ -23,11 +23,11 @@ package authuser
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha512"
"encoding/base64"
"fmt"
"net/mail"
"reflect"
"time"
"git.happydns.org/happyDomain/internal/helpers"
@ -38,18 +38,24 @@ import (
const RegistrationHashValidity = 24 * time.Hour
// GenRegistrationHash generates the validation hash for the current or previous period.
// The hash computation is based on some already filled fields in the structure.
func GenRegistrationHash(u *happydns.UserAuth, previous bool) string {
// The hash uses both CreatedAt and PasswordRecoveryKey as HMAC key material,
// ensuring the hash cannot be forged without knowledge of the secret recovery key.
func GenRegistrationHash(createdAt time.Time, recoveryKey []byte, previous bool) string {
if len(recoveryKey) == 0 {
return ""
}
date := time.Now()
if previous {
date = date.Add(RegistrationHashValidity * -1)
}
date = date.Truncate(RegistrationHashValidity)
h := hmac.New(
sha512.New,
[]byte(u.CreatedAt.Format(time.RFC3339Nano)),
)
// Combine CreatedAt and PasswordRecoveryKey as key material.
// This differentiates from GenAccountRecoveryHash which uses only recoveryKey.
keyMaterial := append([]byte(createdAt.Format(time.RFC3339Nano)), recoveryKey...)
h := hmac.New(sha512.New, keyMaterial)
h.Write(date.AppendFormat([]byte{}, time.RFC3339))
return base64.RawURLEncoding.EncodeToString(h.Sum(nil))
}
@ -70,16 +76,41 @@ func NewEmailValidationUsecase(store AuthUserStorage, mailer happydns.Mailer, co
}
}
// GenerateLink returns the absolute URL corresponding to the recovery
// URL of the given account.
func (uc *EmailValidationUsecase) GenerateLink(user *happydns.UserAuth) string {
return uc.config.GetBaseURL() + fmt.Sprintf("/email-validation?u=%s&k=%s", base64.RawURLEncoding.EncodeToString(user.Id), GenRegistrationHash(user, false))
// GenerateLink returns the absolute URL corresponding to the email
// validation URL of the given account. It generates a PasswordRecoveryKey
// if one does not already exist.
func (uc *EmailValidationUsecase) GenerateLink(user *happydns.UserAuth) (string, error) {
if err := uc.ensureRecoveryKey(user); err != nil {
return "", err
}
hash := GenRegistrationHash(user.CreatedAt, user.PasswordRecoveryKey, false)
return uc.config.GetBaseURL() + fmt.Sprintf("/email-validation?u=%s&k=%s", base64.RawURLEncoding.EncodeToString(user.Id), hash), nil
}
// ensureRecoveryKey generates and persists a PasswordRecoveryKey if the user doesn't have one.
func (uc *EmailValidationUsecase) ensureRecoveryKey(user *happydns.UserAuth) error {
if user.PasswordRecoveryKey != nil {
return nil
}
user.PasswordRecoveryKey = make([]byte, 64)
if _, err := rand.Read(user.PasswordRecoveryKey); err != nil {
return fmt.Errorf("unable to generate recovery key: %w", err)
}
if err := uc.store.UpdateAuthUser(user); err != nil {
return fmt.Errorf("unable to save recovery key: %w", err)
}
return nil
}
// SendLink sends an email validation link to the user's email.
func (uc *EmailValidationUsecase) SendLink(user *happydns.UserAuth) error {
if uc.mailer == nil || reflect.ValueOf(uc.mailer).IsNil() {
return fmt.Errorf("no mailer configured")
link, err := uc.GenerateLink(user)
if err != nil {
return fmt.Errorf("unable to generate validation link: %w", err)
}
toName := helpers.GenUsername(user.Email)
@ -98,13 +129,15 @@ management platform!
In order to validate your account, please follow this link now:
[Validate my account](%s)
`, toName, uc.GenerateLink(user)),
`, toName, link),
)
}
// Validate tries to validate the email address by comparing the given key to the expected one.
func (uc *EmailValidationUsecase) Validate(user *happydns.UserAuth, form happydns.AddressValidationForm) error {
if form.Key != GenRegistrationHash(user, false) && form.Key != GenRegistrationHash(user, true) {
currentHash := GenRegistrationHash(user.CreatedAt, user.PasswordRecoveryKey, false)
previousHash := GenRegistrationHash(user.CreatedAt, user.PasswordRecoveryKey, true)
if currentHash == "" || (form.Key != currentHash && form.Key != previousHash) {
return happydns.ValidationError{Msg: fmt.Sprintf("bad email validation key: the validation address link you follow is invalid or has expired (it is valid during %d hours)", RegistrationHashValidity/time.Hour)}
}

View file

@ -0,0 +1,450 @@
// 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
package check_test
import (
"context"
"testing"
"git.happydns.org/happyDomain/checks"
"git.happydns.org/happyDomain/internal/storage"
"git.happydns.org/happyDomain/internal/storage/inmemory"
kv "git.happydns.org/happyDomain/internal/storage/kvtpl"
uc "git.happydns.org/happyDomain/internal/usecase/check"
"git.happydns.org/happyDomain/model"
)
// ---------------------------------------------------------------------------
// mockCheckerForOptions registered once at package init.
// ---------------------------------------------------------------------------
const testCheckerName = "test-mock-checker-options"
type mockCheckerForOptions struct{}
func (m *mockCheckerForOptions) ID() string { return testCheckerName }
func (m *mockCheckerForOptions) Name() string { return testCheckerName }
func (m *mockCheckerForOptions) Availability() happydns.CheckerAvailability {
return happydns.CheckerAvailability{ApplyToDomain: true}
}
func (m *mockCheckerForOptions) Options() happydns.CheckerOptionsDocumentation {
return happydns.CheckerOptionsDocumentation{
RunOpts: []happydns.CheckerOptionDocumentation{
{Id: "run-param", Default: "run-default"},
},
DomainOpts: []happydns.CheckerOptionDocumentation{
{Id: "domain-autofill", AutoFill: happydns.AutoFillDomainName},
{Id: "domain-param", Default: "domain-default"},
},
UserOpts: []happydns.CheckerOptionDocumentation{
{Id: "user-param"},
},
ServiceOpts: []happydns.CheckerOptionDocumentation{
{Id: "service-param"},
},
}
}
func (m *mockCheckerForOptions) RunCheck(_ context.Context, opts happydns.CheckerOptions, meta map[string]string) (*happydns.CheckResult, error) {
return nil, nil
}
func init() {
checks.RegisterChecker(testCheckerName, &mockCheckerForOptions{})
}
// ---------------------------------------------------------------------------
// Helper: create a fresh in-memory database for each test.
// ---------------------------------------------------------------------------
func newOptionsTestDB(t *testing.T) storage.Storage {
t.Helper()
mem, err := inmemory.NewInMemoryStorage()
if err != nil {
t.Fatalf("failed to create in-memory storage: %v", err)
}
db, err := kv.NewKVDatabase(mem)
if err != nil {
t.Fatalf("failed to create KV database: %v", err)
}
return db
}
func newTestCheckerUsecase(db storage.Storage) happydns.CheckerUsecase {
return uc.NewCheckerUsecase(&happydns.Options{}, db, db)
}
// ---------------------------------------------------------------------------
// GetStoredCheckerOptionsNoDefault tests
// ---------------------------------------------------------------------------
func Test_GetStoredOptions_EmptyStore(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
opts, err := checkerUC.GetStoredCheckerOptionsNoDefault(testCheckerName, nil, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(opts) != 0 {
t.Errorf("expected empty options from empty store, got %v", opts)
}
}
func Test_GetStoredOptions_MergesStored(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
userId, _ := happydns.NewRandomIdentifier()
// Store user-level option.
if err := db.UpdateCheckerConfiguration(testCheckerName, &userId, nil, nil, happydns.CheckerOptions{"user-param": "val"}); err != nil {
t.Fatalf("failed to seed option: %v", err)
}
opts, err := checkerUC.GetStoredCheckerOptionsNoDefault(testCheckerName, &userId, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if opts["user-param"] != "val" {
t.Errorf("expected user-param='val', got %v", opts["user-param"])
}
}
func Test_GetStoredOptions_AutoFillInjects(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
// Create a domain in the db.
domain := &happydns.Domain{
DomainName: "example.com.",
}
if err := db.CreateDomain(domain); err != nil {
t.Fatalf("failed to create domain: %v", err)
}
opts, err := checkerUC.GetStoredCheckerOptionsNoDefault(testCheckerName, nil, &domain.Id, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if opts["domain-autofill"] != "example.com." {
t.Errorf("expected domain-autofill='example.com.', got %v", opts["domain-autofill"])
}
}
func Test_GetStoredOptions_UnknownCheckerReturnsStored(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
// Store options for an unknown checker.
if err := db.UpdateCheckerConfiguration("unknown-checker", nil, nil, nil, happydns.CheckerOptions{"some-param": "some-value"}); err != nil {
t.Fatalf("failed to seed option: %v", err)
}
opts, err := checkerUC.GetStoredCheckerOptionsNoDefault("unknown-checker", nil, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if opts["some-param"] != "some-value" {
t.Errorf("expected some-param='some-value', got %v", opts["some-param"])
}
}
// ---------------------------------------------------------------------------
// BuildMergedCheckerOptions tests
// ---------------------------------------------------------------------------
func Test_BuildMerged_DefaultsFirst(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
merged, err := checkerUC.BuildMergedCheckerOptions(testCheckerName, nil, nil, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if merged["run-param"] != "run-default" {
t.Errorf("expected run-param='run-default', got %v", merged["run-param"])
}
if merged["domain-param"] != "domain-default" {
t.Errorf("expected domain-param='domain-default', got %v", merged["domain-param"])
}
}
func Test_BuildMerged_StoredOverridesDefault(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
domainId, _ := happydns.NewRandomIdentifier()
// Store domain-level option that overrides the default.
if err := db.UpdateCheckerConfiguration(testCheckerName, nil, &domainId, nil, happydns.CheckerOptions{"domain-param": "custom"}); err != nil {
t.Fatalf("failed to seed option: %v", err)
}
merged, err := checkerUC.BuildMergedCheckerOptions(testCheckerName, nil, &domainId, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if merged["domain-param"] != "custom" {
t.Errorf("expected domain-param='custom' (stored overrides default), got %v", merged["domain-param"])
}
}
func Test_BuildMerged_RunOptsOverrideStored(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
// Store an admin-level value for run-param.
if err := db.UpdateCheckerConfiguration(testCheckerName, nil, nil, nil, happydns.CheckerOptions{"run-param": "stored-value"}); err != nil {
t.Fatalf("failed to seed option: %v", err)
}
runOpts := happydns.CheckerOptions{"run-param": "runtime"}
merged, err := checkerUC.BuildMergedCheckerOptions(testCheckerName, nil, nil, nil, runOpts)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if merged["run-param"] != "runtime" {
t.Errorf("expected run-param='runtime' (runOpts wins), got %v", merged["run-param"])
}
}
func Test_BuildMerged_AutoFillWinsOverAll(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
// Create domain in db.
domain := &happydns.Domain{
DomainName: "example.com.",
}
if err := db.CreateDomain(domain); err != nil {
t.Fatalf("failed to create domain: %v", err)
}
// Both stored and runOpts attempt to set domain-autofill.
if err := db.UpdateCheckerConfiguration(testCheckerName, nil, nil, nil, happydns.CheckerOptions{"domain-autofill": "manual-value"}); err != nil {
t.Fatalf("failed to seed option: %v", err)
}
runOpts := happydns.CheckerOptions{"domain-autofill": "runtime-value"}
merged, err := checkerUC.BuildMergedCheckerOptions(testCheckerName, nil, &domain.Id, nil, runOpts)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Auto-fill always wins.
if merged["domain-autofill"] != "example.com." {
t.Errorf("expected domain-autofill='example.com.' (auto-fill wins), got %v", merged["domain-autofill"])
}
}
func Test_BuildMerged_NilAutoFillStoreSkips(t *testing.T) {
db := newOptionsTestDB(t)
// Pass nil as the CheckAutoFillStorage interface (not a typed nil).
checkerUC := uc.NewCheckerUsecase(&happydns.Options{}, db, nil)
domainId, _ := happydns.NewRandomIdentifier()
// Should not panic even when autoFillStore is nil.
merged, err := checkerUC.BuildMergedCheckerOptions(testCheckerName, nil, &domainId, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// domain-autofill should NOT be set (no auto-fill storage available).
if _, ok := merged["domain-autofill"]; ok {
t.Errorf("expected domain-autofill to be absent when autoFillStore is nil, got %v", merged["domain-autofill"])
}
}
// ---------------------------------------------------------------------------
// SetCheckerOptions tests
// ---------------------------------------------------------------------------
func Test_SetOptions_ServiceLevel(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
userId, _ := happydns.NewRandomIdentifier()
domainId, _ := happydns.NewRandomIdentifier()
serviceId, _ := happydns.NewRandomIdentifier()
opts := happydns.CheckerOptions{"service-param": "val"}
if err := checkerUC.SetCheckerOptions(testCheckerName, &userId, &domainId, &serviceId, opts); err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify the configuration was stored at service scope.
configs, err := db.GetCheckerConfiguration(testCheckerName, &userId, &domainId, &serviceId)
if err != nil {
t.Fatalf("failed to retrieve config: %v", err)
}
// Find the service-level entry (UserId, DomainId, ServiceId all set).
found := false
for _, c := range configs {
if c.UserId != nil && c.DomainId != nil && c.ServiceId != nil {
found = true
break
}
}
if !found {
t.Error("expected a service-level configuration entry to be stored")
}
}
func Test_SetOptions_DomainLevel(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
userId, _ := happydns.NewRandomIdentifier()
domainId, _ := happydns.NewRandomIdentifier()
opts := happydns.CheckerOptions{"domain-param": "val"}
if err := checkerUC.SetCheckerOptions(testCheckerName, &userId, &domainId, nil, opts); err != nil {
t.Fatalf("unexpected error: %v", err)
}
configs, err := db.GetCheckerConfiguration(testCheckerName, &userId, &domainId, nil)
if err != nil {
t.Fatalf("failed to retrieve config: %v", err)
}
found := false
for _, c := range configs {
if c.UserId != nil && c.DomainId != nil && c.ServiceId == nil {
found = true
break
}
}
if !found {
t.Error("expected a domain-level configuration entry to be stored")
}
}
func Test_SetOptions_UserLevel(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
userId, _ := happydns.NewRandomIdentifier()
opts := happydns.CheckerOptions{"user-param": "val"}
if err := checkerUC.SetCheckerOptions(testCheckerName, &userId, nil, nil, opts); err != nil {
t.Fatalf("unexpected error: %v", err)
}
configs, err := db.GetCheckerConfiguration(testCheckerName, &userId, nil, nil)
if err != nil {
t.Fatalf("failed to retrieve config: %v", err)
}
found := false
for _, c := range configs {
if c.UserId != nil && c.DomainId == nil && c.ServiceId == nil {
found = true
break
}
}
if !found {
t.Error("expected a user-level configuration entry to be stored")
}
}
func Test_SetOptions_AdminLevel(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
opts := happydns.CheckerOptions{"run-param": "admin-val"}
if err := checkerUC.SetCheckerOptions(testCheckerName, nil, nil, nil, opts); err != nil {
t.Fatalf("unexpected error: %v", err)
}
configs, err := db.GetCheckerConfiguration(testCheckerName, nil, nil, nil)
if err != nil {
t.Fatalf("failed to retrieve config: %v", err)
}
if len(configs) == 0 {
t.Error("expected at least one admin-level configuration entry to be stored")
}
}
func Test_SetOptions_UnknownCheckerErrors(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
opts := happydns.CheckerOptions{"param": "val"}
if err := checkerUC.SetCheckerOptions("unknown-checker-xyz", nil, nil, nil, opts); err == nil {
t.Fatal("expected error for unknown checker")
}
}
// ---------------------------------------------------------------------------
// OverwriteSomeCheckerOptions tests
// ---------------------------------------------------------------------------
func Test_Overwrite_MergesWithExisting(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
// Pre-seed existing options at admin scope.
if err := db.UpdateCheckerConfiguration(testCheckerName, nil, nil, nil, happydns.CheckerOptions{"a": "1"}); err != nil {
t.Fatalf("failed to seed option: %v", err)
}
if err := checkerUC.OverwriteSomeCheckerOptions(testCheckerName, nil, nil, nil, happydns.CheckerOptions{"b": "2"}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Retrieve the stored options and verify both keys are present.
configs, err := db.GetCheckerConfiguration(testCheckerName, nil, nil, nil)
if err != nil {
t.Fatalf("failed to retrieve config: %v", err)
}
if len(configs) == 0 {
t.Fatal("expected at least one config entry")
}
merged := configs[0].Options
if merged["a"] != "1" {
t.Errorf("expected a='1' to be preserved, got %v", merged["a"])
}
if merged["b"] != "2" {
t.Errorf("expected b='2' to be added, got %v", merged["b"])
}
}
func Test_Overwrite_OverridesExistingKey(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
// Pre-seed existing options.
if err := db.UpdateCheckerConfiguration(testCheckerName, nil, nil, nil, happydns.CheckerOptions{"a": "1"}); err != nil {
t.Fatalf("failed to seed option: %v", err)
}
if err := checkerUC.OverwriteSomeCheckerOptions(testCheckerName, nil, nil, nil, happydns.CheckerOptions{"a": "99"}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
configs, err := db.GetCheckerConfiguration(testCheckerName, nil, nil, nil)
if err != nil {
t.Fatalf("failed to retrieve config: %v", err)
}
if len(configs) == 0 {
t.Fatal("expected at least one config entry")
}
if configs[0].Options["a"] != "99" {
t.Errorf("expected a='99' after overwrite, got %v", configs[0].Options["a"])
}
}

View file

@ -0,0 +1,62 @@
// 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
package check
import (
"git.happydns.org/happyDomain/model"
)
type CheckerStorage interface {
// ListAllCheckConfigurations retrieves the list of known Providers.
ListAllCheckerConfigurations() (happydns.Iterator[happydns.CheckerOptions], error)
// ListCheckerConfiguration retrieves all providers own by the given User.
ListCheckerConfiguration(string) ([]*happydns.CheckerOptionsPositional, error)
// GetCheckerConfiguration retrieves the full Provider with the given identifier and owner.
GetCheckerConfiguration(string, *happydns.Identifier, *happydns.Identifier, *happydns.Identifier) ([]*happydns.CheckerOptionsPositional, error)
// UpdateCheckerConfiguration updates the fields of the given Provider.
UpdateCheckerConfiguration(string, *happydns.Identifier, *happydns.Identifier, *happydns.Identifier, happydns.CheckerOptions) error
// DeleteCheckerConfiguration removes the given Provider from the database.
DeleteCheckerConfiguration(string, *happydns.Identifier, *happydns.Identifier, *happydns.Identifier) error
// ClearCheckerConfigurations deletes all Providers present in the database.
ClearCheckerConfigurations() error
}
// CheckAutoFillStorage provides the domain/zone/user lookups needed to
// resolve auto-fill variables for test check options.
type CheckAutoFillStorage interface {
// GetDomain retrieves the Domain with the given identifier.
GetDomain(domainid happydns.Identifier) (*happydns.Domain, error)
// GetUser retrieves the User with the given identifier.
GetUser(userid happydns.Identifier) (*happydns.User, error)
// ListDomains retrieves all Domains associated to the given User.
ListDomains(user *happydns.User) ([]*happydns.Domain, error)
// GetZone retrieves the full Zone (including Services and metadata) for the given identifier.
GetZone(zoneid happydns.Identifier) (*happydns.ZoneMessage, error)
}

View file

@ -0,0 +1,331 @@
// 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
package check
import (
"cmp"
"fmt"
"log"
"maps"
"slices"
"git.happydns.org/happyDomain/checks"
"git.happydns.org/happyDomain/model"
)
type checkerUsecase struct {
config *happydns.Options
store CheckerStorage
autoFillStore CheckAutoFillStorage
}
func NewCheckerUsecase(cfg *happydns.Options, store CheckerStorage, autoFillStore CheckAutoFillStorage) happydns.CheckerUsecase {
return &checkerUsecase{
config: cfg,
store: store,
autoFillStore: autoFillStore,
}
}
func (tu *checkerUsecase) GetChecker(cname string) (happydns.Checker, error) {
checker, err := checks.FindChecker(cname)
if err != nil {
return nil, fmt.Errorf("unable to find check named %q: %w", cname, err)
}
return checker, nil
}
// copyNonEmpty copies key/value pairs from src into dst, skipping nil or empty-string values.
func copyNonEmpty(dst, src happydns.CheckerOptions) {
for k, v := range src {
if v == nil {
continue
}
if s, ok := v.(string); ok && s == "" {
continue
}
dst[k] = v
}
}
func compareIdentifiers(a, b *happydns.Identifier) int {
if a == nil && b == nil {
return 0
}
if a == nil {
return -1
}
if b == nil {
return 1
}
if a.Equals(*b) {
return 0
}
return a.Compare(*b)
}
// CompareCheckerOptionsPositional defines the merge precedence ordering for
// checker option configs: admin < user < domain < service.
func CompareCheckerOptionsPositional(a, b *happydns.CheckerOptionsPositional) int {
if a.CheckName != b.CheckName {
return cmp.Compare(a.CheckName, b.CheckName)
}
if res := compareIdentifiers(a.UserId, b.UserId); res != 0 {
return res
}
if res := compareIdentifiers(a.DomainId, b.DomainId); res != 0 {
return res
}
return compareIdentifiers(a.ServiceId, b.ServiceId)
}
func (tu *checkerUsecase) GetCheckerOptions(cname string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier) (*happydns.CheckerOptions, error) {
configs, err := tu.store.GetCheckerConfiguration(cname, userid, domainid, serviceid)
if err != nil {
return nil, err
}
slices.SortFunc(configs, CompareCheckerOptionsPositional)
opts := make(happydns.CheckerOptions)
for _, c := range configs {
maps.Copy(opts, c.Options)
}
return &opts, nil
}
func (tu *checkerUsecase) ListCheckers() (*map[string]happydns.Checker, error) {
return checks.GetCheckers(), nil
}
// GetStoredCheckerOptionsNoDefault returns the stored options (user/domain/service scopes)
// with auto-fill variables applied, but without checker-defined defaults or run-time overrides.
func (tu *checkerUsecase) GetStoredCheckerOptionsNoDefault(cname string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier) (happydns.CheckerOptions, error) {
stored, err := tu.GetCheckerOptions(cname, userid, domainid, serviceid)
if err != nil {
return nil, err
}
var opts happydns.CheckerOptions
if stored != nil {
opts = *stored
} else {
opts = make(happydns.CheckerOptions)
}
checker, err := tu.GetChecker(cname)
if err != nil {
return opts, nil
}
return tu.applyAutoFill(checker, userid, domainid, serviceid, opts), nil
}
// BuildMergedCheckerOptions merges checker options from all sources in priority order:
// checker defaults < stored (user/domain/service) options < runOpts < auto-fill variables.
func (tu *checkerUsecase) BuildMergedCheckerOptions(cname string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier, runOpts happydns.CheckerOptions) (happydns.CheckerOptions, error) {
merged := make(happydns.CheckerOptions)
// 1. Fill checker defaults.
checker, err := tu.GetChecker(cname)
if err != nil {
log.Printf("Warning: unable to get checker %q for default options: %v", cname, err)
} else {
opts := checker.Options()
allOpts := []happydns.CheckerOptionDocumentation{}
allOpts = append(allOpts, opts.RunOpts...)
allOpts = append(allOpts, opts.ServiceOpts...)
allOpts = append(allOpts, opts.DomainOpts...)
allOpts = append(allOpts, opts.UserOpts...)
allOpts = append(allOpts, opts.AdminOpts...)
for _, opt := range allOpts {
if opt.Default != nil {
merged[opt.Id] = opt.Default
} else if opt.Placeholder != "" {
merged[opt.Id] = opt.Placeholder
}
}
}
// 2. Override with stored options (user/domain/service scopes).
baseOptions, err := tu.GetCheckerOptions(cname, userid, domainid, serviceid)
if err != nil {
return merged, fmt.Errorf("could not fetch stored checker options for %s: %w", cname, err)
}
if baseOptions != nil {
copyNonEmpty(merged, *baseOptions)
}
// 3. Override with caller-supplied run options.
copyNonEmpty(merged, runOpts)
// 4. Inject auto-fill variables (always win over any user-supplied value).
if checker != nil {
merged = tu.applyAutoFill(checker, userid, domainid, serviceid, merged)
}
return merged, nil
}
// applyAutoFill resolves auto-fill fields declared by the checker and injects
// the context-resolved values into a copy of opts.
func (tu *checkerUsecase) applyAutoFill(
checker happydns.Checker,
userid *happydns.Identifier,
domainid *happydns.Identifier,
serviceid *happydns.Identifier,
opts happydns.CheckerOptions,
) happydns.CheckerOptions {
// Collect which auto-fill keys are needed.
needed := make(map[string]string) // autoFill constant → field id
options := checker.Options()
for _, groups := range [][]happydns.CheckerOptionDocumentation{
options.RunOpts, options.DomainOpts, options.ServiceOpts,
options.UserOpts, options.AdminOpts,
} {
for _, opt := range groups {
if opt.AutoFill != "" {
needed[opt.AutoFill] = opt.Id
}
}
}
if len(needed) == 0 || tu.autoFillStore == nil {
return opts
}
autoFillCtx := tu.buildAutoFillContext(userid, domainid, serviceid)
result := maps.Clone(opts)
for autoFillKey, fieldId := range needed {
if val, ok := autoFillCtx[autoFillKey]; ok {
result[fieldId] = val
}
}
return result
}
// buildAutoFillContext resolves the available auto-fill values for the given
// user/domain/service identifiers.
func (tu *checkerUsecase) buildAutoFillContext(userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier) map[string]any {
ctx := make(map[string]any)
if domainid != nil {
if domain, err := tu.autoFillStore.GetDomain(*domainid); err == nil {
ctx[happydns.AutoFillDomainName] = domain.DomainName
if len(domain.ZoneHistory) > 0 {
// The first element in ZoneHistory is the current (most recent) zone.
zoneMsg, err := tu.autoFillStore.GetZone(domain.ZoneHistory[0])
if err == nil {
ctx[happydns.AutoFillZone] = zoneMsg
}
}
}
} else if serviceid != nil && userid != nil {
// To resolve service context we need to find which domain/zone owns the service.
user, err := tu.autoFillStore.GetUser(*userid)
if err != nil {
return ctx
}
domains, err := tu.autoFillStore.ListDomains(user)
if err != nil {
return ctx
}
for _, domain := range domains {
if len(domain.ZoneHistory) == 0 {
continue
}
// The first element in ZoneHistory is the current (most recent) zone.
zoneMsg, err := tu.autoFillStore.GetZone(domain.ZoneHistory[0])
if err != nil {
continue
}
for subdomain, svcs := range zoneMsg.Services {
for _, svc := range svcs {
if svc.Id.Equals(*serviceid) {
ctx[happydns.AutoFillDomainName] = domain.DomainName
ctx[happydns.AutoFillSubdomain] = string(subdomain)
ctx[happydns.AutoFillZone] = zoneMsg
ctx[happydns.AutoFillService] = svc
ctx[happydns.AutoFillServiceType] = svc.Type
return ctx
}
}
}
}
}
return ctx
}
func (tu *checkerUsecase) SetCheckerOptions(cname string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier, opts happydns.CheckerOptions) error {
// filter opts that correspond to the level set
checker, err := tu.GetChecker(cname)
if err != nil {
return fmt.Errorf("unable to get checker: %w", err)
}
options := checker.Options()
var relevantOpts []happydns.CheckerOptionDocumentation
if serviceid != nil {
relevantOpts = options.ServiceOpts
} else if domainid != nil {
relevantOpts = options.DomainOpts
} else if userid != nil {
relevantOpts = options.UserOpts
} else {
relevantOpts = options.AdminOpts
}
allowed := make(map[string]struct{}, len(relevantOpts))
for _, opt := range relevantOpts {
allowed[opt.Id] = struct{}{}
}
filteredOpts := make(happydns.CheckerOptions)
for id := range allowed {
if val, exists := opts[id]; exists && val != "" {
filteredOpts[id] = val
}
}
return tu.store.UpdateCheckerConfiguration(cname, userid, domainid, serviceid, filteredOpts)
}
func (tu *checkerUsecase) OverwriteSomeCheckerOptions(cname string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier, opts happydns.CheckerOptions) error {
current, err := tu.GetCheckerOptions(cname, userid, domainid, serviceid)
if err != nil {
return err
}
maps.Copy(*current, opts)
return tu.store.UpdateCheckerConfiguration(cname, userid, domainid, serviceid, *current)
}

View file

@ -0,0 +1,85 @@
package check_test
import (
"slices"
"testing"
uc "git.happydns.org/happyDomain/internal/usecase/check"
"git.happydns.org/happyDomain/model"
)
func TestSortByCheckName(t *testing.T) {
slice := []*happydns.CheckerOptionsPositional{
{CheckName: "zeta"},
{CheckName: "alpha"},
{CheckName: "beta"},
}
slices.SortFunc(slice, uc.CompareCheckerOptionsPositional)
got := []string{slice[0].CheckName, slice[1].CheckName, slice[2].CheckName}
want := []string{"alpha", "beta", "zeta"}
for i := range want {
if got[i] != want[i] {
t.Errorf("expected %v, got %v", want, got)
break
}
}
}
func TestNilBeforeNonNil(t *testing.T) {
uid, _ := happydns.NewRandomIdentifier()
slice := []*happydns.CheckerOptionsPositional{
{CheckName: "alpha", UserId: &uid},
{CheckName: "alpha", UserId: nil},
}
slices.SortFunc(slice, uc.CompareCheckerOptionsPositional)
if slice[0].UserId != nil {
t.Errorf("expected nil UserId first, got %+v", slice[0].UserId)
}
}
func TestDomainIdOrder(t *testing.T) {
did, _ := happydns.NewRandomIdentifier()
slice := []*happydns.CheckerOptionsPositional{
{CheckName: "alpha", UserId: nil, DomainId: &did},
{CheckName: "alpha", UserId: nil, DomainId: nil},
}
slices.SortFunc(slice, uc.CompareCheckerOptionsPositional)
if slice[0].DomainId != nil {
t.Errorf("expected nil DomainId first, got %+v", slice[0].DomainId)
}
}
func TestServiceIdOrder(t *testing.T) {
sid, _ := happydns.NewRandomIdentifier()
slice := []*happydns.CheckerOptionsPositional{
{CheckName: "alpha", UserId: nil, DomainId: nil, ServiceId: &sid},
{CheckName: "alpha", UserId: nil, DomainId: nil, ServiceId: nil},
}
slices.SortFunc(slice, uc.CompareCheckerOptionsPositional)
if slice[0].ServiceId != nil {
t.Errorf("expected nil ServiceId first, got %+v", slice[0].ServiceId)
}
}
func TestStableGrouping(t *testing.T) {
uid, _ := happydns.NewRandomIdentifier()
slice := []*happydns.CheckerOptionsPositional{
{CheckName: "alpha", UserId: &uid},
{CheckName: "alpha", UserId: &uid},
}
slices.SortFunc(slice, uc.CompareCheckerOptionsPositional)
if slice[0].CheckName != slice[1].CheckName {
t.Errorf("expected grouping, got %+v vs %+v", slice[0], slice[1])
}
}

View file

@ -0,0 +1,362 @@
// 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
package checkresult
import (
"fmt"
"slices"
"time"
"git.happydns.org/happyDomain/model"
)
// CheckResultUsecase implements business logic for check results
type CheckResultUsecase struct {
storage CheckResultStorage
options *happydns.Options
checkerUC happydns.CheckerUsecase
checkerScheduleUC happydns.CheckerScheduleUsecase
}
// NewCheckResultUsecase creates a new check result usecase
func NewCheckResultUsecase(storage CheckResultStorage, options *happydns.Options, checkerUC happydns.CheckerUsecase, checkerScheduleUC happydns.CheckerScheduleUsecase) *CheckResultUsecase {
return &CheckResultUsecase{
storage: storage,
options: options,
checkerUC: checkerUC,
checkerScheduleUC: checkerScheduleUC,
}
}
// ListCheckerStatuses returns all checkers applicable to scope with their schedule
// and most recent result for the given target.
func (u *CheckResultUsecase) ListCheckerStatuses(scope happydns.CheckScopeType, targetID happydns.Identifier, domain *happydns.Domain, service *happydns.Service) ([]happydns.CheckerStatus, error) {
plugins, err := u.checkerUC.ListCheckers()
if err != nil {
return nil, err
}
// Get schedules for this target
schedules, err := u.checkerScheduleUC.ListSchedulesByTarget(scope, targetID)
if err != nil {
return nil, err
}
// Build schedule map
scheduleMap := make(map[string]*happydns.CheckerSchedule, len(schedules))
for _, sched := range schedules {
scheduleMap[sched.CheckerName] = sched
}
// Get service type for LimitToServices filtering
var serviceType string
if scope == happydns.CheckScopeService && service != nil {
serviceType = service.Type
}
// Build response with last results
var statuses []happydns.CheckerStatus
for checkername, check := range *plugins {
// Filter plugins by scope
if scope == happydns.CheckScopeDomain && !check.Availability().ApplyToDomain {
continue
}
if scope == happydns.CheckScopeService && !check.Availability().ApplyToService {
continue
}
// Filter plugins by service type if LimitToServices is set
if scope == happydns.CheckScopeService && serviceType != "" {
limitTo := check.Availability().LimitToServices
if len(limitTo) > 0 && !slices.Contains(limitTo, serviceType) {
continue
}
}
info := happydns.CheckerStatus{
CheckerName: checkername,
NotDiscovered: true,
}
// Check if there's a schedule
if sched, ok := scheduleMap[checkername]; ok {
info.Enabled = sched.Enabled
info.Schedule = sched
info.NotDiscovered = false
// Get last result
results, err := u.ListCheckResultsByTarget(checkername, scope, targetID, 1)
if err == nil && len(results) > 0 {
info.LastResult = results[0]
}
}
statuses = append(statuses, info)
}
return statuses, nil
}
// ListCheckResultsByTarget retrieves check results for a specific target
func (u *CheckResultUsecase) ListCheckResultsByTarget(pluginName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, limit int) ([]*happydns.CheckResult, error) {
// Apply default limit if not specified
if limit <= 0 {
limit = 5 // Default to 5 most recent results
}
return u.storage.ListCheckResults(pluginName, targetType, targetId, limit)
}
// ListAllCheckResultsByTarget retrieves all check results for a target across all plugins
func (u *CheckResultUsecase) ListAllCheckResultsByTarget(targetType happydns.CheckScopeType, targetId happydns.Identifier, userId happydns.Identifier, limit int) ([]*happydns.CheckResult, error) {
// Get all results for the user and filter by target
allResults, err := u.storage.ListCheckResultsByUser(userId, 0)
if err != nil {
return nil, err
}
// Filter by target
var results []*happydns.CheckResult
for _, r := range allResults {
if r.CheckType == targetType && r.TargetId.Equals(targetId) {
results = append(results, r)
}
}
// Apply limit
if limit > 0 && len(results) > limit {
results = results[:limit]
}
return results, nil
}
// GetCheckResult retrieves a specific check result
func (u *CheckResultUsecase) GetCheckResult(pluginName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, resultId happydns.Identifier) (*happydns.CheckResult, error) {
return u.storage.GetCheckResult(pluginName, targetType, targetId, resultId)
}
// CreateCheckResult stores a new check result and enforces retention policy
func (u *CheckResultUsecase) CreateCheckResult(result *happydns.CheckResult) error {
// Store the result
if err := u.storage.CreateCheckResult(result); err != nil {
return err
}
// Enforce retention policy
maxResults := u.options.MaxResultsPerCheck
if maxResults <= 0 {
maxResults = 100 // Default
}
return u.storage.DeleteOldCheckResults(result.CheckerName, result.CheckType, result.TargetId, maxResults)
}
// DeleteCheckResult removes a specific check result
func (u *CheckResultUsecase) DeleteCheckResult(pluginName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, resultId happydns.Identifier) error {
return u.storage.DeleteCheckResult(pluginName, targetType, targetId, resultId)
}
// DeleteAllCheckResults removes all results for a specific plugin+target combination
func (u *CheckResultUsecase) DeleteAllCheckResults(pluginName string, targetType happydns.CheckScopeType, targetId happydns.Identifier) error {
// Get all results first
results, err := u.storage.ListCheckResults(pluginName, targetType, targetId, 0)
if err != nil {
return err
}
// Delete each result
for _, r := range results {
if err := u.storage.DeleteCheckResult(pluginName, targetType, targetId, r.Id); err != nil {
return err
}
}
return nil
}
// CleanupOldResults removes check results older than retention period
func (u *CheckResultUsecase) CleanupOldResults() error {
retentionDays := u.options.ResultRetentionDays
if retentionDays <= 0 {
retentionDays = 90 // Default
}
cutoffTime := time.Now().AddDate(0, 0, -retentionDays)
// Get all results for all users (inefficient but necessary without a time-based index)
// In a production system, you might want to add a time-based index for this
// For now, we'll iterate through results and delete old ones
// This is a placeholder - the actual implementation would need to be optimized
// based on specific storage patterns
_ = cutoffTime
return nil
}
// GetCheckExecution retrieves the status of a check execution
func (u *CheckResultUsecase) GetCheckExecution(executionId happydns.Identifier) (*happydns.CheckExecution, error) {
return u.storage.GetCheckExecution(executionId)
}
// CreateCheckExecution creates a new check execution record
func (u *CheckResultUsecase) CreateCheckExecution(execution *happydns.CheckExecution) error {
if execution.StartedAt.IsZero() {
execution.StartedAt = time.Now()
}
return u.storage.CreateCheckExecution(execution)
}
// UpdateCheckExecution updates an existing check execution
func (u *CheckResultUsecase) UpdateCheckExecution(execution *happydns.CheckExecution) error {
return u.storage.UpdateCheckExecution(execution)
}
// CompleteCheckExecution marks an execution as completed with a result
func (u *CheckResultUsecase) CompleteCheckExecution(executionId happydns.Identifier, resultId happydns.Identifier) error {
execution, err := u.storage.GetCheckExecution(executionId)
if err != nil {
return err
}
now := time.Now()
execution.Status = happydns.CheckExecutionCompleted
execution.CompletedAt = &now
execution.ResultId = &resultId
return u.storage.UpdateCheckExecution(execution)
}
// FailCheckExecution marks an execution as failed
func (u *CheckResultUsecase) FailCheckExecution(executionId happydns.Identifier, errorMsg string) error {
execution, err := u.storage.GetCheckExecution(executionId)
if err != nil {
return err
}
now := time.Now()
execution.Status = happydns.CheckExecutionFailed
execution.CompletedAt = &now
// Store error in a result
result := &happydns.CheckResult{
CheckerName: execution.CheckerName,
CheckType: execution.TargetType,
TargetId: execution.TargetId,
OwnerId: execution.OwnerId,
ExecutedAt: time.Now(),
ScheduledCheck: execution.ScheduleId != nil,
Options: execution.Options,
Status: happydns.CheckResultStatusCritical,
StatusLine: "Execution failed",
Error: errorMsg,
Duration: now.Sub(execution.StartedAt),
}
if err := u.CreateCheckResult(result); err != nil {
return fmt.Errorf("failed to create error result: %w", err)
}
execution.ResultId = &result.Id
return u.storage.UpdateCheckExecution(execution)
}
// GetWorstCheckStatus returns the worst (most critical) status from the most
// recent result of each checker for a given target. Returns nil if no results exist.
func (u *CheckResultUsecase) GetWorstCheckStatus(targetType happydns.CheckScopeType, targetId happydns.Identifier, userId happydns.Identifier) (*happydns.CheckResultStatus, error) {
results, err := u.ListAllCheckResultsByTarget(targetType, targetId, userId, 0)
if err != nil || len(results) == 0 {
return nil, err
}
// Keep only the latest result per checker
latest := map[string]*happydns.CheckResult{}
for _, r := range results {
if prev, ok := latest[r.CheckerName]; !ok || r.ExecutedAt.After(prev.ExecutedAt) {
latest[r.CheckerName] = r
}
}
// Find minimum (worst) status among latest results, ignoring Unknown (which
// means the check couldn't run, not that the domain is in a bad state).
var worst *happydns.CheckResultStatus
for _, r := range latest {
s := r.Status
if s == happydns.CheckResultStatusUnknown {
continue
}
if worst == nil || s < *worst {
worst = &s
}
}
return worst, nil
}
// GetWorstCheckStatusByUser fetches all results for the user once and returns
// a map from target ID string to worst (most critical) status per target.
func (u *CheckResultUsecase) GetWorstCheckStatusByUser(targetType happydns.CheckScopeType, userId happydns.Identifier) (map[string]*happydns.CheckResultStatus, error) {
allResults, err := u.storage.ListCheckResultsByUser(userId, 0)
if err != nil {
return nil, err
}
type key struct {
target string
checker string
}
latest := map[key]*happydns.CheckResult{}
for _, r := range allResults {
if r.CheckType != targetType || r.CheckerName == "" {
continue
}
k := key{target: r.TargetId.String(), checker: r.CheckerName}
if prev, ok := latest[k]; !ok || r.ExecutedAt.After(prev.ExecutedAt) {
latest[k] = r
}
}
worst := map[string]*happydns.CheckResultStatus{}
for k, r := range latest {
s := r.Status
if prev, ok := worst[k.target]; !ok || s < *prev {
worst[k.target] = &s
}
}
return worst, nil
}
// DeleteCompletedExecutions removes execution records that are completed
func (u *CheckResultUsecase) DeleteCompletedExecutions(olderThan time.Duration) error {
cutoffTime := time.Now().Add(-olderThan)
// Get active executions (this won't include completed ones)
// We need a different query to get completed executions older than cutoff
// For now, this is a placeholder
_ = cutoffTime
return nil
}

View file

@ -0,0 +1,490 @@
// 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
package checkresult_test
import (
"testing"
"time"
"git.happydns.org/happyDomain/internal/storage"
"git.happydns.org/happyDomain/internal/storage/inmemory"
kv "git.happydns.org/happyDomain/internal/storage/kvtpl"
checkresultUC "git.happydns.org/happyDomain/internal/usecase/checkresult"
"git.happydns.org/happyDomain/model"
)
// newTestDB creates a fresh in-memory storage for each test.
func newTestDB(t *testing.T) storage.Storage {
t.Helper()
mem, err := inmemory.NewInMemoryStorage()
if err != nil {
t.Fatalf("failed to create in-memory storage: %v", err)
}
db, err := kv.NewKVDatabase(mem)
if err != nil {
t.Fatalf("failed to create KV database: %v", err)
}
return db
}
func newTestCheckResultUsecase(db storage.Storage, maxResults int) *checkresultUC.CheckResultUsecase {
return checkresultUC.NewCheckResultUsecase(db, &happydns.Options{MaxResultsPerCheck: maxResults}, nil, nil)
}
// ---------------------------------------------------------------------------
// CreateCheckResult tests
// ---------------------------------------------------------------------------
func Test_CreateCheckResult_DefaultMaxResults(t *testing.T) {
db := newTestDB(t)
// MaxResultsPerCheck=0 → default 100; verify results are stored correctly.
uc := newTestCheckResultUsecase(db, 0)
targetId, _ := happydns.NewRandomIdentifier()
ownerId, _ := happydns.NewRandomIdentifier()
result := &happydns.CheckResult{
CheckerName: "checker",
CheckType: happydns.CheckScopeDomain,
TargetId: targetId,
OwnerId: ownerId,
ExecutedAt: time.Now(),
}
if err := uc.CreateCheckResult(result); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Id.IsEmpty() {
t.Error("expected result to have a non-empty ID after create")
}
stored, err := db.ListCheckResults("checker", happydns.CheckScopeDomain, targetId, 0)
if err != nil {
t.Fatalf("unexpected error listing results: %v", err)
}
if len(stored) != 1 {
t.Errorf("expected 1 stored result, got %d", len(stored))
}
}
func Test_CreateCheckResult_CustomMaxResults(t *testing.T) {
db := newTestDB(t)
// MaxResultsPerCheck=3: pre-seed 5 results, create 1 more → expect 3 to remain.
uc := newTestCheckResultUsecase(db, 3)
targetId, _ := happydns.NewRandomIdentifier()
ownerId, _ := happydns.NewRandomIdentifier()
for i := range 5 {
r := &happydns.CheckResult{
CheckerName: "checker",
CheckType: happydns.CheckScopeDomain,
TargetId: targetId,
OwnerId: ownerId,
ExecutedAt: time.Now().Add(-time.Duration(5-i) * time.Minute),
}
if err := db.CreateCheckResult(r); err != nil {
t.Fatalf("failed to seed result: %v", err)
}
}
// Creating one more via the usecase triggers retention → prune to 3.
if err := uc.CreateCheckResult(&happydns.CheckResult{
CheckerName: "checker",
CheckType: happydns.CheckScopeDomain,
TargetId: targetId,
OwnerId: ownerId,
ExecutedAt: time.Now(),
}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
remaining, err := db.ListCheckResults("checker", happydns.CheckScopeDomain, targetId, 0)
if err != nil {
t.Fatalf("unexpected error listing results: %v", err)
}
if len(remaining) != 3 {
t.Errorf("expected 3 results after retention (MaxResultsPerCheck=3), got %d", len(remaining))
}
}
func Test_CreateCheckResult_StoresResult(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckResultUsecase(db, 10)
targetId, _ := happydns.NewRandomIdentifier()
ownerId, _ := happydns.NewRandomIdentifier()
result := &happydns.CheckResult{
CheckerName: "checker",
CheckType: happydns.CheckScopeDomain,
TargetId: targetId,
OwnerId: ownerId,
ExecutedAt: time.Now(),
}
if err := uc.CreateCheckResult(result); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Id.IsEmpty() {
t.Error("expected result to have a non-empty ID after create")
}
// Verify retrievable from storage.
fetched, err := db.GetCheckResult("checker", happydns.CheckScopeDomain, targetId, result.Id)
if err != nil {
t.Fatalf("expected result to be retrievable: %v", err)
}
if fetched.Id.IsEmpty() {
t.Error("retrieved result has empty ID")
}
}
// ---------------------------------------------------------------------------
// ListCheckResultsByTarget tests
// ---------------------------------------------------------------------------
func Test_ListCheckResultsByTarget_DefaultLimit(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckResultUsecase(db, 100)
targetId, _ := happydns.NewRandomIdentifier()
ownerId, _ := happydns.NewRandomIdentifier()
for i := range 8 {
r := &happydns.CheckResult{
CheckerName: "checker",
CheckType: happydns.CheckScopeDomain,
TargetId: targetId,
OwnerId: ownerId,
ExecutedAt: time.Now().Add(time.Duration(i) * time.Second),
}
if err := db.CreateCheckResult(r); err != nil {
t.Fatalf("failed to seed result: %v", err)
}
}
results, err := uc.ListCheckResultsByTarget("checker", happydns.CheckScopeDomain, targetId, 0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(results) > 5 {
t.Errorf("expected at most 5 results (default limit), got %d", len(results))
}
}
func Test_ListCheckResultsByTarget_CustomLimit(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckResultUsecase(db, 100)
targetId, _ := happydns.NewRandomIdentifier()
ownerId, _ := happydns.NewRandomIdentifier()
for i := range 4 {
r := &happydns.CheckResult{
CheckerName: "checker",
CheckType: happydns.CheckScopeDomain,
TargetId: targetId,
OwnerId: ownerId,
ExecutedAt: time.Now().Add(time.Duration(i) * time.Second),
}
if err := db.CreateCheckResult(r); err != nil {
t.Fatalf("failed to seed result: %v", err)
}
}
results, err := uc.ListCheckResultsByTarget("checker", happydns.CheckScopeDomain, targetId, 2)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(results) != 2 {
t.Errorf("expected 2 results with limit=2, got %d", len(results))
}
}
// ---------------------------------------------------------------------------
// DeleteAllCheckResults tests
// ---------------------------------------------------------------------------
func Test_DeleteAllCheckResults_Empty(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckResultUsecase(db, 10)
targetId, _ := happydns.NewRandomIdentifier()
if err := uc.DeleteAllCheckResults("checker", happydns.CheckScopeDomain, targetId); err != nil {
t.Fatalf("unexpected error on empty store: %v", err)
}
}
func Test_DeleteAllCheckResults_OnlyTargetDeleted(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckResultUsecase(db, 100)
targetId1, _ := happydns.NewRandomIdentifier()
targetId2, _ := happydns.NewRandomIdentifier()
ownerId, _ := happydns.NewRandomIdentifier()
for range 3 {
r := &happydns.CheckResult{
CheckerName: "checker",
CheckType: happydns.CheckScopeDomain,
TargetId: targetId1,
OwnerId: ownerId,
ExecutedAt: time.Now(),
}
if err := db.CreateCheckResult(r); err != nil {
t.Fatalf("failed to seed targetId1 result: %v", err)
}
}
for range 2 {
r := &happydns.CheckResult{
CheckerName: "checker",
CheckType: happydns.CheckScopeDomain,
TargetId: targetId2,
OwnerId: ownerId,
ExecutedAt: time.Now(),
}
if err := db.CreateCheckResult(r); err != nil {
t.Fatalf("failed to seed targetId2 result: %v", err)
}
}
if err := uc.DeleteAllCheckResults("checker", happydns.CheckScopeDomain, targetId1); err != nil {
t.Fatalf("unexpected error: %v", err)
}
remaining1, _ := db.ListCheckResults("checker", happydns.CheckScopeDomain, targetId1, 0)
if len(remaining1) != 0 {
t.Errorf("expected 0 results for targetId1 after delete, got %d", len(remaining1))
}
remaining2, _ := db.ListCheckResults("checker", happydns.CheckScopeDomain, targetId2, 0)
if len(remaining2) != 2 {
t.Errorf("expected 2 results for targetId2 to remain, got %d", len(remaining2))
}
}
// ---------------------------------------------------------------------------
// CreateCheckExecution tests
// ---------------------------------------------------------------------------
func Test_CreateCheckExecution_SetsStartedAt(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckResultUsecase(db, 10)
targetId, _ := happydns.NewRandomIdentifier()
ownerId, _ := happydns.NewRandomIdentifier()
execution := &happydns.CheckExecution{
CheckerName: "checker",
OwnerId: ownerId,
TargetType: happydns.CheckScopeDomain,
TargetId: targetId,
}
before := time.Now()
if err := uc.CreateCheckExecution(execution); err != nil {
t.Fatalf("unexpected error: %v", err)
}
after := time.Now()
if execution.StartedAt.IsZero() {
t.Error("expected StartedAt to be set")
}
if execution.StartedAt.Before(before) || execution.StartedAt.After(after) {
t.Errorf("StartedAt %v not in expected range [%v, %v]", execution.StartedAt, before, after)
}
}
func Test_CreateCheckExecution_PreservesStartedAt(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckResultUsecase(db, 10)
targetId, _ := happydns.NewRandomIdentifier()
ownerId, _ := happydns.NewRandomIdentifier()
specificTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
execution := &happydns.CheckExecution{
CheckerName: "checker",
OwnerId: ownerId,
TargetType: happydns.CheckScopeDomain,
TargetId: targetId,
StartedAt: specificTime,
}
if err := uc.CreateCheckExecution(execution); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !execution.StartedAt.Equal(specificTime) {
t.Errorf("expected StartedAt to be preserved as %v, got %v", specificTime, execution.StartedAt)
}
}
// ---------------------------------------------------------------------------
// CompleteCheckExecution tests
// ---------------------------------------------------------------------------
func Test_CompleteCheckExecution_SetsStatus(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckResultUsecase(db, 10)
targetId, _ := happydns.NewRandomIdentifier()
ownerId, _ := happydns.NewRandomIdentifier()
resultId, _ := happydns.NewRandomIdentifier()
execution := &happydns.CheckExecution{
CheckerName: "checker",
OwnerId: ownerId,
TargetType: happydns.CheckScopeDomain,
TargetId: targetId,
Status: happydns.CheckExecutionRunning,
StartedAt: time.Now().Add(-time.Second),
}
if err := uc.CreateCheckExecution(execution); err != nil {
t.Fatalf("failed to create execution: %v", err)
}
before := time.Now()
if err := uc.CompleteCheckExecution(execution.Id, resultId); err != nil {
t.Fatalf("unexpected error: %v", err)
}
after := time.Now()
updated, err := db.GetCheckExecution(execution.Id)
if err != nil {
t.Fatalf("failed to retrieve execution: %v", err)
}
if updated.Status != happydns.CheckExecutionCompleted {
t.Errorf("expected status Completed, got %v", updated.Status)
}
if updated.CompletedAt == nil {
t.Error("expected CompletedAt to be set")
} else if updated.CompletedAt.Before(before) || updated.CompletedAt.After(after) {
t.Errorf("CompletedAt %v not in expected range [%v, %v]", *updated.CompletedAt, before, after)
}
if updated.ResultId == nil || !updated.ResultId.Equals(resultId) {
t.Error("expected ResultId to be set to the provided resultId")
}
}
func Test_CompleteCheckExecution_NotFound(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckResultUsecase(db, 10)
nonExistentId, _ := happydns.NewRandomIdentifier()
resultId, _ := happydns.NewRandomIdentifier()
if err := uc.CompleteCheckExecution(nonExistentId, resultId); err == nil {
t.Fatal("expected error for non-existent execution ID")
}
}
// ---------------------------------------------------------------------------
// FailCheckExecution tests
// ---------------------------------------------------------------------------
func Test_FailCheckExecution_CreatesErrorResult(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckResultUsecase(db, 10)
targetId, _ := happydns.NewRandomIdentifier()
ownerId, _ := happydns.NewRandomIdentifier()
execution := &happydns.CheckExecution{
CheckerName: "checker",
OwnerId: ownerId,
TargetType: happydns.CheckScopeDomain,
TargetId: targetId,
Status: happydns.CheckExecutionRunning,
StartedAt: time.Now().Add(-time.Second),
}
if err := uc.CreateCheckExecution(execution); err != nil {
t.Fatalf("failed to create execution: %v", err)
}
if err := uc.FailCheckExecution(execution.Id, "something went wrong"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify the check result was created.
results, err := db.ListCheckResults("checker", happydns.CheckScopeDomain, targetId, 0)
if err != nil {
t.Fatalf("failed to list results: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 check result to be created on failure, got %d", len(results))
}
result := results[0]
if result.Status != happydns.CheckResultStatusCritical {
t.Errorf("expected Status=KO, got %v", result.Status)
}
if result.Error != "something went wrong" {
t.Errorf("expected Error='something went wrong', got %q", result.Error)
}
// Verify the execution status was updated.
updated, err := db.GetCheckExecution(execution.Id)
if err != nil {
t.Fatalf("failed to retrieve execution: %v", err)
}
if updated.Status != happydns.CheckExecutionFailed {
t.Errorf("expected execution status Failed, got %v", updated.Status)
}
}
func Test_FailCheckExecution_ScheduledCheckFlag(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckResultUsecase(db, 10)
targetId, _ := happydns.NewRandomIdentifier()
ownerId, _ := happydns.NewRandomIdentifier()
scheduleId, _ := happydns.NewRandomIdentifier()
execution := &happydns.CheckExecution{
CheckerName: "checker",
OwnerId: ownerId,
TargetType: happydns.CheckScopeDomain,
TargetId: targetId,
ScheduleId: &scheduleId,
Status: happydns.CheckExecutionRunning,
StartedAt: time.Now().Add(-time.Second),
}
if err := uc.CreateCheckExecution(execution); err != nil {
t.Fatalf("failed to create execution: %v", err)
}
if err := uc.FailCheckExecution(execution.Id, "scheduled check failed"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
results, err := db.ListCheckResults("checker", happydns.CheckScopeDomain, targetId, 0)
if err != nil {
t.Fatalf("failed to list results: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
if !results[0].ScheduledCheck {
t.Error("expected ScheduledCheck=true when execution has a non-nil ScheduleId")
}
}

View file

@ -0,0 +1,410 @@
// 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
package checkresult
import (
"errors"
"fmt"
"math/rand"
"sort"
"time"
"git.happydns.org/happyDomain/model"
)
const (
// Default check intervals
DefaultUserCheckInterval = 4 * time.Hour // 4 hours for user checks
DefaultDomainCheckInterval = 24 * time.Hour // 24 hours for domain checks
DefaultServiceCheckInterval = 1 * time.Hour // 1 hour for service checks
MinimumCheckInterval = 5 * time.Minute // Minimum interval allowed
)
// CheckScheduleUsecase implements business logic for check schedules
type CheckScheduleUsecase struct {
storage CheckResultStorage
options *happydns.Options
domainLister DomainLister
checkerUsecase happydns.CheckerUsecase
}
// NewCheckScheduleUsecase creates a new check schedule usecase
func NewCheckScheduleUsecase(storage CheckResultStorage, options *happydns.Options, domainLister DomainLister, checkerUsecase happydns.CheckerUsecase) *CheckScheduleUsecase {
return &CheckScheduleUsecase{
storage: storage,
options: options,
domainLister: domainLister,
checkerUsecase: checkerUsecase,
}
}
// ListUserSchedules retrieves all schedules for a specific user
func (u *CheckScheduleUsecase) ListUserSchedules(userId happydns.Identifier) ([]*happydns.CheckerSchedule, error) {
return u.storage.ListCheckerSchedulesByUser(userId)
}
// ListSchedulesByTarget retrieves all schedules for a specific target
func (u *CheckScheduleUsecase) ListSchedulesByTarget(targetType happydns.CheckScopeType, targetId happydns.Identifier) ([]*happydns.CheckerSchedule, error) {
return u.storage.ListCheckerSchedulesByTarget(targetType, targetId)
}
// GetSchedule retrieves a specific schedule by ID
func (u *CheckScheduleUsecase) GetSchedule(scheduleId happydns.Identifier) (*happydns.CheckerSchedule, error) {
return u.storage.GetCheckerSchedule(scheduleId)
}
// CreateSchedule creates a new check schedule with validation
func (u *CheckScheduleUsecase) CreateSchedule(schedule *happydns.CheckerSchedule) error {
// Set default interval if not specified
if schedule.Interval == 0 {
schedule.Interval = u.getDefaultInterval(schedule.TargetType)
}
// Validate interval
if schedule.Interval < MinimumCheckInterval {
return fmt.Errorf("check interval must be at least %v", MinimumCheckInterval)
}
// Calculate next run time: pick a random offset within the interval
// to spread load evenly across all schedules
// TODO: Use a smarter load balance function in the future
if schedule.NextRun.IsZero() {
offset := time.Duration(rand.Int63n(int64(schedule.Interval)))
schedule.NextRun = time.Now().Add(offset)
}
return u.storage.CreateCheckerSchedule(schedule)
}
// UpdateSchedule updates an existing schedule
func (u *CheckScheduleUsecase) UpdateSchedule(schedule *happydns.CheckerSchedule) error {
// Validate interval
if schedule.Interval < MinimumCheckInterval {
return fmt.Errorf("check interval must be at least %v", MinimumCheckInterval)
}
// Get existing schedule to preserve certain fields
existing, err := u.storage.GetCheckerSchedule(schedule.Id)
if err != nil {
return err
}
// Preserve LastRun if not explicitly changed
if schedule.LastRun == nil {
schedule.LastRun = existing.LastRun
}
// Recalculate next run time if interval changed
if schedule.Interval != existing.Interval {
if schedule.LastRun != nil {
schedule.NextRun = schedule.LastRun.Add(schedule.Interval)
} else {
schedule.NextRun = time.Now().Add(schedule.Interval)
}
}
return u.storage.UpdateCheckerSchedule(schedule)
}
// DeleteSchedule removes a schedule
func (u *CheckScheduleUsecase) DeleteSchedule(scheduleId happydns.Identifier) error {
return u.storage.DeleteCheckerSchedule(scheduleId)
}
// EnableSchedule enables a schedule
func (u *CheckScheduleUsecase) EnableSchedule(scheduleId happydns.Identifier) error {
schedule, err := u.storage.GetCheckerSchedule(scheduleId)
if err != nil {
return err
}
schedule.Enabled = true
// Reset next run time if it's in the past
if schedule.NextRun.Before(time.Now()) {
schedule.NextRun = time.Now().Add(schedule.Interval)
}
return u.storage.UpdateCheckerSchedule(schedule)
}
// DisableSchedule disables a schedule
func (u *CheckScheduleUsecase) DisableSchedule(scheduleId happydns.Identifier) error {
schedule, err := u.storage.GetCheckerSchedule(scheduleId)
if err != nil {
return err
}
schedule.Enabled = false
return u.storage.UpdateCheckerSchedule(schedule)
}
// UpdateScheduleAfterRun updates a schedule after it has been executed
func (u *CheckScheduleUsecase) UpdateScheduleAfterRun(scheduleId happydns.Identifier) error {
schedule, err := u.storage.GetCheckerSchedule(scheduleId)
if err != nil {
return err
}
now := time.Now()
schedule.LastRun = &now
schedule.NextRun = now.Add(schedule.Interval)
return u.storage.UpdateCheckerSchedule(schedule)
}
// ListDueSchedules retrieves all enabled schedules that are due to run
func (u *CheckScheduleUsecase) ListDueSchedules() ([]*happydns.CheckerSchedule, error) {
schedules, err := u.storage.ListEnabledCheckerSchedules()
if err != nil {
return nil, err
}
now := time.Now()
var dueSchedules []*happydns.CheckerSchedule
for _, schedule := range schedules {
if schedule.NextRun.Before(now) {
dueSchedules = append(dueSchedules, schedule)
}
}
return dueSchedules, nil
}
// ListUpcomingSchedules retrieves the next `limit` enabled schedules sorted by NextRun ascending
func (u *CheckScheduleUsecase) ListUpcomingSchedules(limit int) ([]*happydns.CheckerSchedule, error) {
schedules, err := u.storage.ListEnabledCheckerSchedules()
if err != nil {
return nil, err
}
sort.Slice(schedules, func(i, j int) bool {
return schedules[i].NextRun.Before(schedules[j].NextRun)
})
if limit > 0 && len(schedules) > limit {
schedules = schedules[:limit]
}
return schedules, nil
}
// getDefaultInterval returns the default check interval based on target type
func (u *CheckScheduleUsecase) getDefaultInterval(targetType happydns.CheckScopeType) time.Duration {
switch targetType {
case happydns.CheckScopeUser:
return DefaultUserCheckInterval
case happydns.CheckScopeDomain:
return DefaultDomainCheckInterval
case happydns.CheckScopeService:
return DefaultServiceCheckInterval
default:
return DefaultDomainCheckInterval
}
}
// ValidateScheduleOwnership checks if a user owns a schedule
func (u *CheckScheduleUsecase) ValidateScheduleOwnership(scheduleId happydns.Identifier, userId happydns.Identifier) error {
schedule, err := u.storage.GetCheckerSchedule(scheduleId)
if err != nil {
return err
}
if !schedule.OwnerId.Equals(userId) {
return fmt.Errorf("user does not own this schedule")
}
return nil
}
// CreateDefaultSchedulesForTarget creates default schedules for a new target
func (u *CheckScheduleUsecase) CreateDefaultSchedulesForTarget(
checkerName string,
targetType happydns.CheckScopeType,
targetId happydns.Identifier,
ownerId happydns.Identifier,
enabled bool,
) error {
schedule := &happydns.CheckerSchedule{
CheckerName: checkerName,
OwnerId: ownerId,
TargetType: targetType,
TargetId: targetId,
Interval: u.getDefaultInterval(targetType),
Enabled: enabled,
NextRun: time.Now().Add(u.getDefaultInterval(targetType)),
Options: make(happydns.CheckerOptions),
}
return u.CreateSchedule(schedule)
}
// rescheduleChecks reschedules each given schedule to a random time in [now, now+maxOffsetFn(schedule)].
func (u *CheckScheduleUsecase) rescheduleChecks(schedules []*happydns.CheckerSchedule, maxOffsetFn func(*happydns.CheckerSchedule) time.Duration) (int, error) {
count := 0
now := time.Now()
for _, schedule := range schedules {
maxOffset := maxOffsetFn(schedule)
if maxOffset <= 0 {
maxOffset = time.Second
}
schedule.NextRun = now.Add(time.Duration(rand.Int63n(int64(maxOffset))))
if err := u.storage.UpdateCheckerSchedule(schedule); err != nil {
return count, err
}
count++
}
return count, nil
}
// RescheduleUpcomingChecks randomizes the next run time of all enabled schedules
// within their respective intervals to spread load evenly. Useful after a restart.
func (u *CheckScheduleUsecase) RescheduleUpcomingChecks() (int, error) {
schedules, err := u.storage.ListEnabledCheckerSchedules()
if err != nil {
return 0, err
}
return u.rescheduleChecks(schedules, func(s *happydns.CheckerSchedule) time.Duration {
return s.Interval
})
}
// RescheduleOverdueChecks reschedules checks whose NextRun is in the past,
// spreading them over a short window to avoid scheduler famine (e.g. after
// a long machine suspend or server downtime).
// If there are fewer than 10 overdue checks, they are left as-is so that the
// caller's immediate checkSchedules pass enqueues them directly.
func (u *CheckScheduleUsecase) RescheduleOverdueChecks() (int, error) {
schedules, err := u.storage.ListEnabledCheckerSchedules()
if err != nil {
return 0, err
}
now := time.Now()
var overdue []*happydns.CheckerSchedule
for _, s := range schedules {
if s.NextRun.Before(now) {
overdue = append(overdue, s)
}
}
if len(overdue) == 0 {
return 0, nil
}
// Small backlog: let the caller enqueue them directly on the next
// checkSchedules pass rather than deferring them into the future.
if len(overdue) < 10 {
return 0, nil
}
// Spread overdue checks over a small window proportional to their count,
// capped at MinimumCheckInterval, to prevent all of them from running at once.
spreadWindow := time.Duration(len(overdue)) * 5 * time.Second
if spreadWindow > MinimumCheckInterval {
spreadWindow = MinimumCheckInterval
}
return u.rescheduleChecks(overdue, func(s *happydns.CheckerSchedule) time.Duration {
return spreadWindow
})
}
// DeleteSchedulesForTarget removes all schedules for a target
func (u *CheckScheduleUsecase) DeleteSchedulesForTarget(targetType happydns.CheckScopeType, targetId happydns.Identifier) error {
schedules, err := u.storage.ListCheckerSchedulesByTarget(targetType, targetId)
if err != nil {
return err
}
for _, schedule := range schedules {
if err := u.storage.DeleteCheckerSchedule(schedule.Id); err != nil {
return err
}
}
return nil
}
// DiscoverAndEnsureSchedules creates default enabled schedules for all (plugin, domain)
// pairs that don't yet have an explicit schedule record. This implements the opt-out
// model: checks run automatically unless a schedule with Enabled=false has been saved.
// Non-fatal per-domain errors are collected and returned together.
func (u *CheckScheduleUsecase) DiscoverAndEnsureSchedules() error {
if u.domainLister == nil || u.checkerUsecase == nil {
return nil
}
plugins, err := u.checkerUsecase.ListCheckers()
if err != nil {
return fmt.Errorf("listing check plugins for discovery: %w", err)
}
iter, err := u.domainLister.ListAllDomains()
if err != nil {
return fmt.Errorf("listing domains for schedule discovery: %w", err)
}
defer iter.Close()
var errs []error
for iter.Next() {
domain := iter.Item()
if domain == nil {
continue
}
for checkerName, p := range *plugins {
if !p.Availability().ApplyToDomain {
continue
}
schedules, err := u.ListSchedulesByTarget(happydns.CheckScopeDomain, domain.Id)
if err != nil {
errs = append(errs, fmt.Errorf("listing schedules for domain %s: %w", domain.Id, err))
continue
}
hasSchedule := false
for _, sched := range schedules {
if sched.CheckerName == checkerName {
hasSchedule = true
break
}
}
if !hasSchedule {
if err := u.CreateSchedule(&happydns.CheckerSchedule{
CheckerName: checkerName,
OwnerId: domain.Owner,
TargetType: happydns.CheckScopeDomain,
TargetId: domain.Id,
Enabled: true,
}); err != nil {
errs = append(errs, fmt.Errorf("auto-creating schedule for domain %s / plugin %s: %w",
domain.Id, checkerName, err))
}
}
}
}
return errors.Join(errs...)
}

View file

@ -0,0 +1,890 @@
// 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
package checkresult_test
import (
"context"
"fmt"
"testing"
"time"
"git.happydns.org/happyDomain/internal/storage"
checkresultUC "git.happydns.org/happyDomain/internal/usecase/checkresult"
"git.happydns.org/happyDomain/model"
)
// ---------------------------------------------------------------------------
// mockCheckerUsecase minimal CheckerUsecase backed by a fixed map.
// ---------------------------------------------------------------------------
type mockCheckerUsecase struct {
checkers map[string]happydns.Checker
}
func (m *mockCheckerUsecase) ListCheckers() (*map[string]happydns.Checker, error) {
return &m.checkers, nil
}
func (m *mockCheckerUsecase) GetChecker(name string) (happydns.Checker, error) {
c, ok := m.checkers[name]
if !ok {
return nil, fmt.Errorf("checker not found: %s", name)
}
return c, nil
}
func (m *mockCheckerUsecase) GetCheckerOptions(name string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier) (*happydns.CheckerOptions, error) {
return nil, nil
}
func (m *mockCheckerUsecase) BuildMergedCheckerOptions(name string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier, runOpts happydns.CheckerOptions) (happydns.CheckerOptions, error) {
return runOpts, nil
}
func (m *mockCheckerUsecase) GetStoredCheckerOptionsNoDefault(name string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier) (happydns.CheckerOptions, error) {
return make(happydns.CheckerOptions), nil
}
func (m *mockCheckerUsecase) SetCheckerOptions(name string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier, opts happydns.CheckerOptions) error {
return nil
}
func (m *mockCheckerUsecase) OverwriteSomeCheckerOptions(name string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier, opts happydns.CheckerOptions) error {
return nil
}
// ---------------------------------------------------------------------------
// mockDomainChecker Checker with configurable Availability.
// ---------------------------------------------------------------------------
type mockDomainChecker struct {
name string
applyDomain bool
applyService bool
}
func (m *mockDomainChecker) ID() string { return m.name }
func (m *mockDomainChecker) Name() string { return m.name }
func (m *mockDomainChecker) Availability() happydns.CheckerAvailability {
return happydns.CheckerAvailability{ApplyToDomain: m.applyDomain, ApplyToService: m.applyService}
}
func (m *mockDomainChecker) Options() happydns.CheckerOptionsDocumentation {
return happydns.CheckerOptionsDocumentation{}
}
func (m *mockDomainChecker) RunCheck(_ context.Context, opts happydns.CheckerOptions, meta map[string]string) (*happydns.CheckResult, error) {
return nil, nil
}
// ---------------------------------------------------------------------------
// Constructor helper
// ---------------------------------------------------------------------------
func newTestCheckScheduleUsecase(db storage.Storage, checkerUC happydns.CheckerUsecase) *checkresultUC.CheckScheduleUsecase {
return checkresultUC.NewCheckScheduleUsecase(db, &happydns.Options{}, db, checkerUC)
}
// seedSchedule creates and stores a schedule in the db, returning it with its assigned ID.
func seedSchedule(t *testing.T, db storage.Storage, interval time.Duration) *happydns.CheckerSchedule {
t.Helper()
ownerId, _ := happydns.NewRandomIdentifier()
targetId, _ := happydns.NewRandomIdentifier()
s := &happydns.CheckerSchedule{
CheckerName: "checker",
OwnerId: ownerId,
TargetType: happydns.CheckScopeDomain,
TargetId: targetId,
Interval: interval,
Enabled: true,
NextRun: time.Now().Add(interval),
}
if err := db.CreateCheckerSchedule(s); err != nil {
t.Fatalf("failed to seed schedule: %v", err)
}
return s
}
// ---------------------------------------------------------------------------
// CreateSchedule tests
// ---------------------------------------------------------------------------
func Test_CreateSchedule_DefaultInterval_Domain(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckScheduleUsecase(db, nil)
ownerId, _ := happydns.NewRandomIdentifier()
targetId, _ := happydns.NewRandomIdentifier()
schedule := &happydns.CheckerSchedule{
CheckerName: "checker",
OwnerId: ownerId,
TargetType: happydns.CheckScopeDomain,
TargetId: targetId,
Enabled: true,
}
if err := uc.CreateSchedule(schedule); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if schedule.Interval != checkresultUC.DefaultDomainCheckInterval {
t.Errorf("expected default domain interval %v, got %v", checkresultUC.DefaultDomainCheckInterval, schedule.Interval)
}
}
func Test_CreateSchedule_DefaultInterval_Service(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckScheduleUsecase(db, nil)
ownerId, _ := happydns.NewRandomIdentifier()
targetId, _ := happydns.NewRandomIdentifier()
schedule := &happydns.CheckerSchedule{
CheckerName: "checker",
OwnerId: ownerId,
TargetType: happydns.CheckScopeService,
TargetId: targetId,
Enabled: true,
}
if err := uc.CreateSchedule(schedule); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if schedule.Interval != checkresultUC.DefaultServiceCheckInterval {
t.Errorf("expected default service interval %v, got %v", checkresultUC.DefaultServiceCheckInterval, schedule.Interval)
}
}
func Test_CreateSchedule_MinimumIntervalRejected(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckScheduleUsecase(db, nil)
ownerId, _ := happydns.NewRandomIdentifier()
targetId, _ := happydns.NewRandomIdentifier()
schedule := &happydns.CheckerSchedule{
CheckerName: "checker",
OwnerId: ownerId,
TargetType: happydns.CheckScopeDomain,
TargetId: targetId,
Interval: 4 * time.Minute,
Enabled: true,
}
if err := uc.CreateSchedule(schedule); err == nil {
t.Fatal("expected error for interval below minimum (4 minutes)")
}
}
func Test_CreateSchedule_ExactMinimumAccepted(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckScheduleUsecase(db, nil)
ownerId, _ := happydns.NewRandomIdentifier()
targetId, _ := happydns.NewRandomIdentifier()
schedule := &happydns.CheckerSchedule{
CheckerName: "checker",
OwnerId: ownerId,
TargetType: happydns.CheckScopeDomain,
TargetId: targetId,
Interval: checkresultUC.MinimumCheckInterval,
Enabled: true,
}
if err := uc.CreateSchedule(schedule); err != nil {
t.Errorf("expected no error for exactly minimum interval, got: %v", err)
}
}
func Test_CreateSchedule_NextRunSetWhenZero(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckScheduleUsecase(db, nil)
ownerId, _ := happydns.NewRandomIdentifier()
targetId, _ := happydns.NewRandomIdentifier()
interval := 30 * time.Minute
schedule := &happydns.CheckerSchedule{
CheckerName: "checker",
OwnerId: ownerId,
TargetType: happydns.CheckScopeDomain,
TargetId: targetId,
Interval: interval,
Enabled: true,
}
before := time.Now()
if err := uc.CreateSchedule(schedule); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if schedule.NextRun.IsZero() {
t.Fatal("expected NextRun to be set")
}
// NextRun = now + rand(0, interval), so NextRun is in [before, before+interval).
if schedule.NextRun.Before(before) || schedule.NextRun.After(before.Add(interval)) {
t.Errorf("NextRun %v not in expected range [%v, %v+interval]", schedule.NextRun, before, before)
}
}
func Test_CreateSchedule_NextRunPreserved(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckScheduleUsecase(db, nil)
ownerId, _ := happydns.NewRandomIdentifier()
targetId, _ := happydns.NewRandomIdentifier()
specificNextRun := time.Now().Add(3 * time.Hour)
schedule := &happydns.CheckerSchedule{
CheckerName: "checker",
OwnerId: ownerId,
TargetType: happydns.CheckScopeDomain,
TargetId: targetId,
Interval: 30 * time.Minute,
Enabled: true,
NextRun: specificNextRun,
}
if err := uc.CreateSchedule(schedule); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !schedule.NextRun.Equal(specificNextRun) {
t.Errorf("expected NextRun to be preserved as %v, got %v", specificNextRun, schedule.NextRun)
}
}
// ---------------------------------------------------------------------------
// UpdateSchedule tests
// ---------------------------------------------------------------------------
func Test_UpdateSchedule_PreservesLastRun(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckScheduleUsecase(db, nil)
existing := seedSchedule(t, db, time.Hour)
lastRun := time.Now().Add(-30 * time.Minute)
existing.LastRun = &lastRun
// Store LastRun into the db.
if err := db.UpdateCheckerSchedule(existing); err != nil {
t.Fatalf("failed to persist LastRun: %v", err)
}
// Update without setting LastRun → should be preserved from existing.
update := *existing
update.LastRun = nil
if err := uc.UpdateSchedule(&update); err != nil {
t.Fatalf("unexpected error: %v", err)
}
stored, err := db.GetCheckerSchedule(existing.Id)
if err != nil {
t.Fatalf("failed to retrieve schedule: %v", err)
}
if stored.LastRun == nil {
t.Error("expected LastRun to be preserved from existing schedule")
} else if !stored.LastRun.Equal(lastRun) {
t.Errorf("expected LastRun %v, got %v", lastRun, *stored.LastRun)
}
}
func Test_UpdateSchedule_RecalculatesNextRun(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckScheduleUsecase(db, nil)
existing := seedSchedule(t, db, time.Hour)
lastRun := time.Now().Add(-20 * time.Minute)
existing.LastRun = &lastRun
if err := db.UpdateCheckerSchedule(existing); err != nil {
t.Fatalf("failed to persist LastRun: %v", err)
}
newInterval := 2 * time.Hour
update := *existing
update.Interval = newInterval
if err := uc.UpdateSchedule(&update); err != nil {
t.Fatalf("unexpected error: %v", err)
}
stored, err := db.GetCheckerSchedule(existing.Id)
if err != nil {
t.Fatalf("failed to retrieve schedule: %v", err)
}
expectedNextRun := lastRun.Add(newInterval)
if !stored.NextRun.Equal(expectedNextRun) {
t.Errorf("expected NextRun=%v (LastRun+newInterval), got %v", expectedNextRun, stored.NextRun)
}
}
func Test_UpdateSchedule_NextRunFromNowWhenNoLastRun(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckScheduleUsecase(db, nil)
existing := seedSchedule(t, db, time.Hour)
// Ensure no LastRun in stored version.
existing.LastRun = nil
if err := db.UpdateCheckerSchedule(existing); err != nil {
t.Fatalf("failed to persist cleared LastRun: %v", err)
}
newInterval := 2 * time.Hour
update := *existing
update.Interval = newInterval
before := time.Now()
if err := uc.UpdateSchedule(&update); err != nil {
t.Fatalf("unexpected error: %v", err)
}
after := time.Now()
stored, err := db.GetCheckerSchedule(existing.Id)
if err != nil {
t.Fatalf("failed to retrieve schedule: %v", err)
}
lowerBound := before.Add(newInterval)
upperBound := after.Add(newInterval)
if stored.NextRun.Before(lowerBound) || stored.NextRun.After(upperBound) {
t.Errorf("expected NextRun in [%v, %v], got %v", lowerBound, upperBound, stored.NextRun)
}
}
func Test_UpdateSchedule_MinimumIntervalRejected(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckScheduleUsecase(db, nil)
existing := seedSchedule(t, db, time.Hour)
update := *existing
update.Interval = 2 * time.Minute
if err := uc.UpdateSchedule(&update); err == nil {
t.Fatal("expected error for interval below minimum")
}
}
// ---------------------------------------------------------------------------
// EnableSchedule / DisableSchedule tests
// ---------------------------------------------------------------------------
func Test_EnableSchedule_SetsEnabled(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckScheduleUsecase(db, nil)
existing := seedSchedule(t, db, time.Hour)
existing.Enabled = false
existing.NextRun = time.Now().Add(time.Hour) // future, no reset needed
if err := db.UpdateCheckerSchedule(existing); err != nil {
t.Fatalf("failed to persist disabled schedule: %v", err)
}
if err := uc.EnableSchedule(existing.Id); err != nil {
t.Fatalf("unexpected error: %v", err)
}
stored, err := db.GetCheckerSchedule(existing.Id)
if err != nil {
t.Fatalf("failed to retrieve schedule: %v", err)
}
if !stored.Enabled {
t.Error("expected Enabled=true after EnableSchedule")
}
}
func Test_EnableSchedule_ResetsNextRunIfPast(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckScheduleUsecase(db, nil)
existing := seedSchedule(t, db, time.Hour)
existing.Enabled = false
existing.NextRun = time.Now().Add(-time.Hour) // in the past
if err := db.UpdateCheckerSchedule(existing); err != nil {
t.Fatalf("failed to persist past NextRun: %v", err)
}
before := time.Now()
if err := uc.EnableSchedule(existing.Id); err != nil {
t.Fatalf("unexpected error: %v", err)
}
after := time.Now()
stored, err := db.GetCheckerSchedule(existing.Id)
if err != nil {
t.Fatalf("failed to retrieve schedule: %v", err)
}
lowerBound := before.Add(existing.Interval)
upperBound := after.Add(existing.Interval)
if stored.NextRun.Before(lowerBound) || stored.NextRun.After(upperBound) {
t.Errorf("expected NextRun in [%v, %v] after enable, got %v", lowerBound, upperBound, stored.NextRun)
}
}
func Test_DisableSchedule_SetsDisabled(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckScheduleUsecase(db, nil)
existing := seedSchedule(t, db, time.Hour)
// It's already Enabled=true from seedSchedule.
if err := uc.DisableSchedule(existing.Id); err != nil {
t.Fatalf("unexpected error: %v", err)
}
stored, err := db.GetCheckerSchedule(existing.Id)
if err != nil {
t.Fatalf("failed to retrieve schedule: %v", err)
}
if stored.Enabled {
t.Error("expected Enabled=false after DisableSchedule")
}
}
// ---------------------------------------------------------------------------
// ListDueSchedules tests
// ---------------------------------------------------------------------------
func Test_ListDueSchedules_FiltersDisabledAndFuture(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckScheduleUsecase(db, nil)
ownerId, _ := happydns.NewRandomIdentifier()
targetId, _ := happydns.NewRandomIdentifier()
pastTime := time.Now().Add(-time.Minute)
futureTime := time.Now().Add(time.Hour)
makeAndStore := func(enabled bool, nextRun time.Time) *happydns.CheckerSchedule {
s := &happydns.CheckerSchedule{
CheckerName: "checker",
OwnerId: ownerId,
TargetType: happydns.CheckScopeDomain,
TargetId: targetId,
Interval: time.Hour,
Enabled: enabled,
NextRun: nextRun,
}
if err := db.CreateCheckerSchedule(s); err != nil {
t.Fatalf("failed to create schedule: %v", err)
}
return s
}
enabledDue := makeAndStore(true, pastTime)
_ = makeAndStore(false, pastTime) // disabled + past → not returned
_ = makeAndStore(true, futureTime) // enabled + future → not returned
due, err := uc.ListDueSchedules()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(due) != 1 {
t.Errorf("expected 1 due schedule, got %d", len(due))
}
if len(due) > 0 && !due[0].Id.Equals(enabledDue.Id) {
t.Errorf("expected the enabled+past schedule to be returned")
}
}
func Test_ListDueSchedules_Empty(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckScheduleUsecase(db, nil)
due, err := uc.ListDueSchedules()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(due) != 0 {
t.Errorf("expected 0 due schedules, got %d", len(due))
}
}
// ---------------------------------------------------------------------------
// ListUpcomingSchedules tests
// ---------------------------------------------------------------------------
func Test_ListUpcomingSchedules_SortedAscending(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckScheduleUsecase(db, nil)
ownerId, _ := happydns.NewRandomIdentifier()
targetId, _ := happydns.NewRandomIdentifier()
now := time.Now()
// Insert in reverse order (far future first) to ensure sorting is needed.
for i := 3; i >= 1; i-- {
s := &happydns.CheckerSchedule{
CheckerName: "checker",
OwnerId: ownerId,
TargetType: happydns.CheckScopeDomain,
TargetId: targetId,
Interval: time.Hour,
Enabled: true,
NextRun: now.Add(time.Duration(i) * time.Hour),
}
if err := db.CreateCheckerSchedule(s); err != nil {
t.Fatalf("failed to create schedule: %v", err)
}
}
upcoming, err := uc.ListUpcomingSchedules(0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
for i := 1; i < len(upcoming); i++ {
if upcoming[i].NextRun.Before(upcoming[i-1].NextRun) {
t.Errorf("schedules not in ascending order at index %d: %v > %v",
i, upcoming[i-1].NextRun, upcoming[i].NextRun)
}
}
}
func Test_ListUpcomingSchedules_LimitApplied(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckScheduleUsecase(db, nil)
ownerId, _ := happydns.NewRandomIdentifier()
targetId, _ := happydns.NewRandomIdentifier()
now := time.Now()
for i := range 5 {
s := &happydns.CheckerSchedule{
CheckerName: "checker",
OwnerId: ownerId,
TargetType: happydns.CheckScopeDomain,
TargetId: targetId,
Interval: time.Hour,
Enabled: true,
NextRun: now.Add(time.Duration(i) * time.Hour),
}
if err := db.CreateCheckerSchedule(s); err != nil {
t.Fatalf("failed to create schedule: %v", err)
}
}
upcoming, err := uc.ListUpcomingSchedules(3)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(upcoming) != 3 {
t.Errorf("expected 3 schedules with limit=3, got %d", len(upcoming))
}
}
// ---------------------------------------------------------------------------
// ValidateScheduleOwnership tests
// ---------------------------------------------------------------------------
func Test_ValidateScheduleOwnership_Match(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckScheduleUsecase(db, nil)
existing := seedSchedule(t, db, time.Hour)
if err := uc.ValidateScheduleOwnership(existing.Id, existing.OwnerId); err != nil {
t.Errorf("expected no error for matching owner, got: %v", err)
}
}
func Test_ValidateScheduleOwnership_Mismatch(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckScheduleUsecase(db, nil)
existing := seedSchedule(t, db, time.Hour)
wrongUserId, _ := happydns.NewRandomIdentifier()
if err := uc.ValidateScheduleOwnership(existing.Id, wrongUserId); err == nil {
t.Fatal("expected error for wrong owner")
}
}
// ---------------------------------------------------------------------------
// RescheduleOverdueChecks tests
// ---------------------------------------------------------------------------
func createOverdueSchedules(t *testing.T, db storage.Storage, n int) []*happydns.CheckerSchedule {
t.Helper()
ownerId, _ := happydns.NewRandomIdentifier()
targetId, _ := happydns.NewRandomIdentifier()
pastTime := time.Now().Add(-2 * time.Hour)
schedules := make([]*happydns.CheckerSchedule, n)
for i := range n {
s := &happydns.CheckerSchedule{
CheckerName: "checker",
OwnerId: ownerId,
TargetType: happydns.CheckScopeDomain,
TargetId: targetId,
Interval: time.Hour,
Enabled: true,
NextRun: pastTime,
}
if err := db.CreateCheckerSchedule(s); err != nil {
t.Fatalf("failed to create overdue schedule: %v", err)
}
schedules[i] = s
}
return schedules
}
func Test_RescheduleOverdueChecks_FewOverdue_NoChange(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckScheduleUsecase(db, nil)
schedules := createOverdueSchedules(t, db, 5)
originalNextRuns := make([]time.Time, len(schedules))
for i, s := range schedules {
originalNextRuns[i] = s.NextRun
}
count, err := uc.RescheduleOverdueChecks()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if count != 0 {
t.Errorf("expected count=0 for fewer than 10 overdue schedules, got %d", count)
}
// NextRun should not have changed.
for i, s := range schedules {
stored, err := db.GetCheckerSchedule(s.Id)
if err != nil {
t.Fatalf("failed to retrieve schedule: %v", err)
}
if !stored.NextRun.Equal(originalNextRuns[i]) {
t.Errorf("schedule[%d] NextRun changed when it should not have", i)
}
}
}
func Test_RescheduleOverdueChecks_ManyOverdue_Rescheduled(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckScheduleUsecase(db, nil)
schedules := createOverdueSchedules(t, db, 15)
before := time.Now()
count, err := uc.RescheduleOverdueChecks()
after := time.Now()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if count != 15 {
t.Errorf("expected count=15, got %d", count)
}
// All schedules should now have NextRun in [before, before+MinimumCheckInterval].
for i, s := range schedules {
stored, err := db.GetCheckerSchedule(s.Id)
if err != nil {
t.Fatalf("failed to retrieve schedule[%d]: %v", i, err)
}
if !stored.NextRun.After(before) {
t.Errorf("schedule[%d] NextRun %v should be after %v", i, stored.NextRun, before)
}
upperBound := after.Add(checkresultUC.MinimumCheckInterval)
if stored.NextRun.After(upperBound) {
t.Errorf("schedule[%d] NextRun %v should not exceed %v", i, stored.NextRun, upperBound)
}
}
}
func Test_RescheduleOverdueChecks_FutureSchedulesIgnored(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckScheduleUsecase(db, nil)
overdue := createOverdueSchedules(t, db, 15)
_ = overdue // created in db
// Add 3 future enabled schedules.
ownerId, _ := happydns.NewRandomIdentifier()
targetId, _ := happydns.NewRandomIdentifier()
futureTime := time.Now().Add(2 * time.Hour)
var futureSchedules []*happydns.CheckerSchedule
for range 3 {
s := &happydns.CheckerSchedule{
CheckerName: "checker",
OwnerId: ownerId,
TargetType: happydns.CheckScopeDomain,
TargetId: targetId,
Interval: time.Hour,
Enabled: true,
NextRun: futureTime,
}
if err := db.CreateCheckerSchedule(s); err != nil {
t.Fatalf("failed to create future schedule: %v", err)
}
futureSchedules = append(futureSchedules, s)
}
if _, err := uc.RescheduleOverdueChecks(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Future schedules should retain their original NextRun.
for i, s := range futureSchedules {
stored, err := db.GetCheckerSchedule(s.Id)
if err != nil {
t.Fatalf("failed to retrieve future schedule[%d]: %v", i, err)
}
if !stored.NextRun.Equal(futureTime) {
t.Errorf("future schedule[%d] NextRun changed from %v to %v", i, futureTime, stored.NextRun)
}
}
}
// ---------------------------------------------------------------------------
// DiscoverAndEnsureSchedules tests
// ---------------------------------------------------------------------------
func createDomain(t *testing.T, db storage.Storage, name string) *happydns.Domain {
t.Helper()
ownerId, _ := happydns.NewRandomIdentifier()
domain := &happydns.Domain{
Owner: ownerId,
DomainName: name,
}
if err := db.CreateDomain(domain); err != nil {
t.Fatalf("failed to create domain %s: %v", name, err)
}
return domain
}
func Test_DiscoverAndEnsureSchedules_CreatesForMissingPlugin(t *testing.T) {
db := newTestDB(t)
domain := createDomain(t, db, "example.com.")
checkerUC := &mockCheckerUsecase{
checkers: map[string]happydns.Checker{
"domain-checker": &mockDomainChecker{name: "domain-checker", applyDomain: true},
},
}
uc := newTestCheckScheduleUsecase(db, checkerUC)
if err := uc.DiscoverAndEnsureSchedules(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
schedules, err := db.ListCheckerSchedulesByTarget(happydns.CheckScopeDomain, domain.Id)
if err != nil {
t.Fatalf("failed to list schedules: %v", err)
}
if len(schedules) != 1 {
t.Errorf("expected 1 schedule created, got %d", len(schedules))
}
}
func Test_DiscoverAndEnsureSchedules_SkipsExistingSchedule(t *testing.T) {
db := newTestDB(t)
domain := createDomain(t, db, "example.com.")
checkerUC := &mockCheckerUsecase{
checkers: map[string]happydns.Checker{
"domain-checker": &mockDomainChecker{name: "domain-checker", applyDomain: true},
},
}
// Pre-seed a schedule for this domain + checker.
pre := &happydns.CheckerSchedule{
CheckerName: "domain-checker",
OwnerId: domain.Owner,
TargetType: happydns.CheckScopeDomain,
TargetId: domain.Id,
Interval: 24 * time.Hour,
Enabled: true,
NextRun: time.Now().Add(time.Hour),
}
if err := db.CreateCheckerSchedule(pre); err != nil {
t.Fatalf("failed to seed schedule: %v", err)
}
uc := newTestCheckScheduleUsecase(db, checkerUC)
if err := uc.DiscoverAndEnsureSchedules(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
schedules, err := db.ListCheckerSchedulesByTarget(happydns.CheckScopeDomain, domain.Id)
if err != nil {
t.Fatalf("failed to list schedules: %v", err)
}
if len(schedules) != 1 {
t.Errorf("expected 1 schedule (no duplicate), got %d", len(schedules))
}
}
func Test_DiscoverAndEnsureSchedules_SkipsServiceOnlyChecker(t *testing.T) {
db := newTestDB(t)
domain := createDomain(t, db, "example.com.")
checkerUC := &mockCheckerUsecase{
checkers: map[string]happydns.Checker{
"service-only": &mockDomainChecker{name: "service-only", applyDomain: false, applyService: true},
},
}
uc := newTestCheckScheduleUsecase(db, checkerUC)
if err := uc.DiscoverAndEnsureSchedules(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
schedules, err := db.ListCheckerSchedulesByTarget(happydns.CheckScopeDomain, domain.Id)
if err != nil {
t.Fatalf("failed to list schedules: %v", err)
}
if len(schedules) != 0 {
t.Errorf("expected 0 schedules for service-only checker, got %d", len(schedules))
}
}
func Test_DiscoverAndEnsureSchedules_NilDependencies(t *testing.T) {
db := newTestDB(t)
// Both domainLister and checkerUsecase are nil → returns nil, no panic.
uc := checkresultUC.NewCheckScheduleUsecase(db, &happydns.Options{}, nil, nil)
if err := uc.DiscoverAndEnsureSchedules(); err != nil {
t.Errorf("expected nil error for nil dependencies, got: %v", err)
}
}
func Test_DiscoverAndEnsureSchedules_MultipleDomains(t *testing.T) {
db := newTestDB(t)
createDomain(t, db, "alpha.com.")
createDomain(t, db, "beta.com.")
createDomain(t, db, "gamma.com.")
checkerUC := &mockCheckerUsecase{
checkers: map[string]happydns.Checker{
"checker-1": &mockDomainChecker{name: "checker-1", applyDomain: true},
"checker-2": &mockDomainChecker{name: "checker-2", applyDomain: true},
},
}
uc := newTestCheckScheduleUsecase(db, checkerUC)
if err := uc.DiscoverAndEnsureSchedules(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
// 3 domains × 2 checkers = 6 schedules.
enabled, err := db.ListEnabledCheckerSchedules()
if err != nil {
t.Fatalf("failed to list schedules: %v", err)
}
if len(enabled) != 6 {
t.Errorf("expected 6 schedules (3 domains × 2 checkers), got %d", len(enabled))
}
}

View file

@ -0,0 +1,103 @@
// 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
package checkresult
import (
"time"
"git.happydns.org/happyDomain/model"
)
// CheckResultStorage defines the storage interface for check results and related data
type CheckResultStorage interface {
// Check Results
// ListCheckResults retrieves check results for a specific plugin+target combination
ListCheckResults(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, limit int) ([]*happydns.CheckResult, error)
// ListCheckResultsByPlugin retrieves all check results for a plugin across all targets for a user
ListCheckResultsByPlugin(userId happydns.Identifier, checkName string, limit int) ([]*happydns.CheckResult, error)
// ListCheckResultsByUser retrieves all check results for a user
ListCheckResultsByUser(userId happydns.Identifier, limit int) ([]*happydns.CheckResult, error)
// GetCheckResult retrieves a specific check result by its ID
GetCheckResult(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, resultId happydns.Identifier) (*happydns.CheckResult, error)
// CreateCheckResult stores a new check result
CreateCheckResult(result *happydns.CheckResult) error
// DeleteCheckResult removes a specific check result
DeleteCheckResult(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, resultId happydns.Identifier) error
// DeleteOldCheckResults removes old check results keeping only the most recent N results
DeleteOldCheckResults(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, keepCount int) error
// Checker Schedules
// ListEnabledCheckerSchedules retrieves all enabled schedules (for scheduler)
ListEnabledCheckerSchedules() ([]*happydns.CheckerSchedule, error)
// ListCheckerSchedulesByUser retrieves all schedules for a specific user
ListCheckerSchedulesByUser(userId happydns.Identifier) ([]*happydns.CheckerSchedule, error)
// ListCheckerSchedulesByTarget retrieves all schedules for a specific target
ListCheckerSchedulesByTarget(targetType happydns.CheckScopeType, targetId happydns.Identifier) ([]*happydns.CheckerSchedule, error)
// GetCheckerSchedule retrieves a specific schedule by ID
GetCheckerSchedule(scheduleId happydns.Identifier) (*happydns.CheckerSchedule, error)
// CreateCheckerSchedule creates a new check schedule
CreateCheckerSchedule(schedule *happydns.CheckerSchedule) error
// UpdateCheckerSchedule updates an existing schedule
UpdateCheckerSchedule(schedule *happydns.CheckerSchedule) error
// DeleteCheckerSchedule removes a schedule
DeleteCheckerSchedule(scheduleId happydns.Identifier) error
// Check Executions
// ListActiveCheckExecutions retrieves all executions that are pending or running
ListActiveCheckExecutions() ([]*happydns.CheckExecution, error)
// GetCheckExecution retrieves a specific execution by ID
GetCheckExecution(executionId happydns.Identifier) (*happydns.CheckExecution, error)
// CreateCheckExecution creates a new check execution record
CreateCheckExecution(execution *happydns.CheckExecution) error
// UpdateCheckExecution updates an existing execution record
UpdateCheckExecution(execution *happydns.CheckExecution) error
// DeleteCheckExecution removes an execution record
DeleteCheckExecution(executionId happydns.Identifier) error
// Scheduler State
// CheckSchedulerRun marks that the scheduler has run at current time
CheckSchedulerRun() error
// LastCheckSchedulerRun retrieves the last time the scheduler ran
LastCheckSchedulerRun() (*time.Time, error)
}
// DomainLister provides access to domain listings for schedule discovery.
type DomainLister interface {
ListAllDomains() (happydns.Iterator[happydns.Domain], error)
}

28
internal/usecase/doc.go Normal file
View file

@ -0,0 +1,28 @@
// 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
// Package usecase implements the top-level application use cases for
// happyDomain. Each file wires together lower-level domain services to fulfil
// a single business capability: user authentication, DNS resolution, provider
// settings wizard, and database tidy-up. These usecases are consumed directly
// by the HTTP/API layer and delegate persistence to the storage interfaces
// defined in the sub-packages.
package usecase

View file

@ -0,0 +1,27 @@
// 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
// Package domain implements the use cases for managing DNS domains in
// happyDomain. It covers the full lifecycle of a domain: creation (including
// existence verification against the provider), retrieval, update, and
// deletion. It also exposes helpers for enriching a domain with its zone
// metadata history.
package domain

View file

@ -122,7 +122,7 @@ func createTestProvider(t *testing.T, store storage.Storage, user *happydns.User
func setupTestService(store storage.Storage) (*domain.Service, *mockDomainLogAppender) {
// Create the provider service
providerService := providerUC.NewService(store)
providerService := providerUC.NewService(store, nil)
// Create the zone usecase
getZone := zoneUC.NewGetZoneUsecase(store)

View file

@ -0,0 +1,28 @@
// 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
// Package domainlog implements the use cases for domain audit-log management.
// Each significant action performed on a domain (import, publish, error) is
// recorded as a DomainLog entry. The Service type exposes CRUD operations on
// those entries, returning them sorted by date (newest first). The
// DomainLogAppender interface is intentionally narrow so other packages can
// append log entries without depending on the full Service.
package domainlog

View file

@ -0,0 +1,37 @@
// 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
package domainlog
import (
"log"
"git.happydns.org/happyDomain/model"
)
// NoopDomainLogAppender is a fallback implementation of DomainLogAppender
// that prints log entries to stdout instead of persisting them.
type NoopDomainLogAppender struct{}
func (NoopDomainLogAppender) AppendDomainLog(domain *happydns.Domain, entry *happydns.DomainLog) error {
log.Printf("domain=%s %s\n", domain.DomainName, entry.Content)
return nil
}

View file

@ -25,16 +25,21 @@ import (
"git.happydns.org/happyDomain/model"
)
// formUsecase implements happydns.FormUsecase, providing form-related helpers
// such as base URL generation used when building dynamic forms.
type formUsecase struct {
config *happydns.Options
}
// NewFormUsecase returns a FormUsecase backed by the given application options.
func NewFormUsecase(cfg *happydns.Options) happydns.FormUsecase {
return &formUsecase{
config: cfg,
}
}
// GetBaseURL returns the application's base URL, used when constructing
// absolute links inside forms (e.g. OAuth redirect URIs).
func (fu *formUsecase) GetBaseURL() string {
return fu.config.GetBaseURL()
}

View file

@ -48,7 +48,14 @@ type CollectStorage interface {
}
// Collect gathers anonymous usage statistics about the running instance.
func Collect(cfg *happydns.Options, store CollectStorage, instanceID string, version happydns.VersionResponse, buildSettings map[string]string, goVersion string) (*happydns.Insights, error) {
func Collect(
cfg *happydns.Options,
store CollectStorage,
instanceID string,
version happydns.VersionResponse,
buildSettings map[string]string,
goVersion string,
) (*happydns.Insights, error) {
data := happydns.Insights{
InsightsID: instanceID,
Version: version,

View file

@ -0,0 +1,27 @@
// 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
// Package insight collects anonymous usage statistics about a running
// happyDomain instance. The Collect function aggregates counters from the
// storage layer (number of users, providers, domains, zones) together with
// build metadata and runtime configuration flags into a single Insights
// value that can be reported to the telemetry endpoint.
package insight

View file

@ -19,9 +19,16 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Package orchestrator wires together lower-level use-cases to implement the
// multi-step workflows that span provider access, zone storage, and domain
// history management. It sits between the HTTP/API layer and the individual
// domain/zone use-cases, coordinating the sequence of operations required to
// import, diff, and publish DNS zones.
package orchestrator
import (
"context"
domainlogUC "git.happydns.org/happyDomain/internal/usecase/domain_log"
zoneUC "git.happydns.org/happyDomain/internal/usecase/zone"
"git.happydns.org/happyDomain/model"
@ -39,20 +46,32 @@ type ProviderGetter interface {
// ZoneRetriever is an interface for retrieving zones from providers.
type ZoneRetriever interface {
RetrieveZone(provider *happydns.Provider, name string) ([]happydns.Record, error)
RetrieveZone(ctx context.Context, provider *happydns.Provider, name string) ([]happydns.Record, error)
}
// ZoneCorrector is an interface for getting zone corrections.
type ZoneCorrector interface {
ListZoneCorrections(provider *happydns.Provider, domain *happydns.Domain, records []happydns.Record) ([]*happydns.Correction, int, error)
ListZoneCorrections(ctx context.Context, provider *happydns.Provider, domain *happydns.Domain, records []happydns.Record) ([]*happydns.Correction, int, error)
}
// 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.
type Orchestrator struct {
RemoteZoneImporter *RemoteZoneImporterUsecase
// RemoteZoneImporter fetches a live zone from the provider and stores it.
RemoteZoneImporter *RemoteZoneImporterUsecase
// ZoneCorrectionApplier lists and applies the corrections needed to bring
// the provider in sync with the desired zone state.
ZoneCorrectionApplier *ZoneCorrectionApplierUsecase
ZoneImporter *ZoneImporterUsecase
// ZoneImporter converts a flat list of DNS records into a happyDomain zone
// and persists it in the domain history.
ZoneImporter *ZoneImporterUsecase
}
// NewOrchestrator constructs an Orchestrator by wiring up all required
// dependencies. It builds the shared ZoneImporterUsecase and
// ZoneCorrectionListerUsecase internally so callers do not need to manage
// those intermediate objects.
func NewOrchestrator(
appendDomainLog domainlogUC.DomainLogAppender,
domainUpdater DomainUpdater,
@ -60,13 +79,15 @@ func NewOrchestrator(
listRecords *zoneUC.ListRecordsUsecase,
zoneCorrectorService ZoneCorrector,
zoneCreator *zoneUC.CreateZoneUsecase,
zoneGetter *zoneUC.GetZoneUsecase,
zoneRetrieverService ZoneRetriever,
zoneUpdater *zoneUC.UpdateZoneUsecase,
) *Orchestrator {
zoneImporter := NewZoneImporterUsecase(domainUpdater, zoneCreator)
zoneImporter := NewZoneImporterUsecase(domainUpdater, zoneCreator, zoneGetter)
zoneCorrectionLister := NewZoneCorrectionListerUsecase(providerService, listRecords, zoneCorrectorService, zoneRetrieverService)
return &Orchestrator{
RemoteZoneImporter: NewRemoteZoneImporterUsecase(appendDomainLog, providerService, zoneImporter, zoneRetrieverService),
ZoneCorrectionApplier: NewZoneCorrectionApplierUsecase(appendDomainLog, domainUpdater, providerService, listRecords, zoneCorrectorService, zoneCreator, zoneUpdater),
ZoneCorrectionApplier: NewZoneCorrectionApplierUsecase(appendDomainLog, domainUpdater, zoneCorrectionLister, zoneCreator, zoneGetter, zoneUpdater),
ZoneImporter: zoneImporter,
}
}

View file

@ -22,23 +22,30 @@
package orchestrator
import (
"context"
"fmt"
"log"
domainlogUC "git.happydns.org/happyDomain/internal/usecase/domain_log"
"git.happydns.org/happyDomain/model"
)
// RemoteZoneImporterUsecase fetches the live DNS records for a domain directly
// 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 *ZoneImporterUsecase
zoneImporter happydns.ZoneImporterUsecase
zoneRetriever ZoneRetriever
}
// NewRemoteZoneImporterUsecase creates a RemoteZoneImporterUsecase wired to
// the given log appender, provider getter, zone importer, and zone retriever.
func NewRemoteZoneImporterUsecase(
appendDomainLog domainlogUC.DomainLogAppender,
providerService ProviderGetter,
zoneImporter *ZoneImporterUsecase,
zoneImporter happydns.ZoneImporterUsecase,
zoneRetriever ZoneRetriever,
) *RemoteZoneImporterUsecase {
return &RemoteZoneImporterUsecase{
@ -49,25 +56,27 @@ func NewRemoteZoneImporterUsecase(
}
}
func (uc *RemoteZoneImporterUsecase) Import(user *happydns.User, domain *happydns.Domain) (*happydns.Zone, error) {
// Import resolves the provider for the domain, retrieves its current records,
// and imports them via ZoneImporterUsecase. A domain log entry is appended on
// success. Returns the newly created zone or an error.
func (uc *RemoteZoneImporterUsecase) Import(ctx context.Context, user *happydns.User, domain *happydns.Domain) (*happydns.Zone, error) {
provider, err := uc.providerService.GetUserProvider(user, domain.ProviderId)
if err != nil {
return nil, err
}
zone, err := uc.zoneRetriever.RetrieveZone(provider, domain.DomainName)
zone, err := uc.zoneRetriever.RetrieveZone(ctx, provider, domain.DomainName)
if err != nil {
return nil, happydns.ValidationError{Msg: fmt.Sprintf("unable to retrieve the zone from server: %s", err.Error())}
return nil, fmt.Errorf("unable to retrieve the zone from server: %w", err)
}
// import
myZone, err := uc.zoneImporter.Import(user, domain, zone)
if err != nil {
return nil, err
}
if uc.appendDomainLog != nil {
uc.appendDomainLog.AppendDomainLog(domain, happydns.NewDomainLog(user, happydns.LOG_INFO, fmt.Sprintf("Zone imported from provider API: %s", myZone.Id.String())))
if err := uc.appendDomainLog.AppendDomainLog(domain, happydns.NewDomainLog(user, happydns.LOG_INFO, fmt.Sprintf("Zone imported from provider API: %s", myZone.Id.String()))); err != nil {
log.Printf("unable to append domain log for %s: %s", domain.DomainName, err.Error())
}
return myZone, nil

View file

@ -22,152 +22,219 @@
package orchestrator
import (
"errors"
"context"
"fmt"
"log"
"time"
adapter "git.happydns.org/happyDomain/internal/adapters"
domainlogUC "git.happydns.org/happyDomain/internal/usecase/domain_log"
zoneUC "git.happydns.org/happyDomain/internal/usecase/zone"
"git.happydns.org/happyDomain/model"
svcs "git.happydns.org/happyDomain/services"
)
// ZoneCorrectionApplierUsecase applies a user-selected subset of zone
// corrections to the provider and, on success, creates a published snapshot
// in the domain history. The WIP zone at ZoneHistory[0] is never modified.
type ZoneCorrectionApplierUsecase struct {
*ZoneCorrectionListerUsecase
appendDomainLog domainlogUC.DomainLogAppender
domainUpdater DomainUpdater
providerService ProviderGetter
listRecords *zoneUC.ListRecordsUsecase
zoneCorrector ZoneCorrector
zoneCreator *zoneUC.CreateZoneUsecase
zoneGetter *zoneUC.GetZoneUsecase
zoneUpdater *zoneUC.UpdateZoneUsecase
clock func() time.Time
}
// NewZoneCorrectionApplierUsecase creates a ZoneCorrectionApplierUsecase with
// the given dependencies. The lister is embedded so that Apply can compute
// the full correction diff in a single call.
func NewZoneCorrectionApplierUsecase(
appendDomainLog domainlogUC.DomainLogAppender,
domainUpdater DomainUpdater,
providerService ProviderGetter,
listRecords *zoneUC.ListRecordsUsecase,
zoneCorrector ZoneCorrector,
lister *ZoneCorrectionListerUsecase,
zoneCreator *zoneUC.CreateZoneUsecase,
zoneGetter *zoneUC.GetZoneUsecase,
zoneUpdater *zoneUC.UpdateZoneUsecase,
) *ZoneCorrectionApplierUsecase {
return &ZoneCorrectionApplierUsecase{
appendDomainLog: appendDomainLog,
domainUpdater: domainUpdater,
providerService: providerService,
listRecords: listRecords,
zoneCorrector: zoneCorrector,
zoneCreator: zoneCreator,
zoneUpdater: zoneUpdater,
ZoneCorrectionListerUsecase: lister,
appendDomainLog: appendDomainLog,
domainUpdater: domainUpdater,
zoneCreator: zoneCreator,
zoneGetter: zoneGetter,
zoneUpdater: zoneUpdater,
clock: time.Now,
}
}
func (uc *ZoneCorrectionApplierUsecase) Apply(user *happydns.User, domain *happydns.Domain, zone *happydns.Zone, form *happydns.ApplyZoneForm) (*happydns.Zone, error) {
// computeExecutableCorrections computes the executable corrections for the
// given selection. It performs the diff, builds the target record set, and asks
// the provider what it would execute to reach that target state.
func (uc *ZoneCorrectionApplierUsecase) computeExecutableCorrections(
ctx context.Context,
user *happydns.User,
domain *happydns.Domain,
zone *happydns.Zone,
wantedCorrections []happydns.Identifier,
) (execCorrections []*happydns.Correction, targetRecords []happydns.Record, nbDiffs int, err error) {
// Step 1: Compute the diff and get provider/WIP records.
corrections, providerRecords, _, nbDiffs, err := uc.listWithRecords(ctx, user, domain, zone)
if err != nil {
return nil, nil, nbDiffs, err
}
// Step 2: Build target records from selected corrections.
targetRecords = adapter.BuildTargetRecords(providerRecords, corrections, wantedCorrections)
// Step 3: Get executable corrections from the provider for the target state.
provider, err := uc.providerService.GetUserProvider(user, domain.ProviderId)
if err != nil {
return nil, nil, nbDiffs, err
}
execCorrections, nbDiffs, err = uc.zoneCorrector.ListZoneCorrections(ctx, provider, domain, targetRecords)
if err != nil {
return nil, nil, nbDiffs, fmt.Errorf("unable to compute executable corrections: %w", err)
}
return execCorrections, targetRecords, nbDiffs, nil
}
// Prepare computes the executable corrections for the given selection without
// applying them. This lets the user see exactly what the provider will execute
// before confirming.
func (uc *ZoneCorrectionApplierUsecase) Prepare(
ctx context.Context,
user *happydns.User,
domain *happydns.Domain,
zone *happydns.Zone,
form *happydns.PrepareZoneForm,
) (*happydns.PrepareZoneResponse, error) {
execCorrections, _, nbDiffs, err := uc.computeExecutableCorrections(ctx, user, domain, zone, form.WantedCorrections)
if err != nil {
return nil, err
}
records, err := uc.listRecords.List(domain, zone)
return &happydns.PrepareZoneResponse{
Corrections: execCorrections,
NbDiffs: nbDiffs,
}, nil
}
// Apply executes the selected corrections against the provider and creates a
// published snapshot zone inserted at ZoneHistory[1] (after the WIP zone at
// position 0). The WIP zone is never modified.
//
// Flow:
// 1. Compute the diff (corrections + provider/WIP records)
// 2. Build the target record set from selected corrections
// 3. Ask the provider to compute executable corrections for the target state
// 4. Execute all returned corrections
// 5. Create a published snapshot zone from the target records
// 6. Insert the snapshot at ZoneHistory[1]
// 7. Return the published snapshot zone
func (uc *ZoneCorrectionApplierUsecase) Apply(
ctx context.Context,
user *happydns.User,
domain *happydns.Domain,
zone *happydns.Zone,
form *happydns.ApplyZoneForm,
) (*happydns.Zone, error) {
executableCorrections, targetRecords, _, err := uc.computeExecutableCorrections(ctx, user, domain, zone, form.WantedCorrections)
if err != nil {
return nil, happydns.InternalError{
Err: fmt.Errorf("unable to retrieve records for zone: %w", err),
}
return nil, err
}
nbcorrections := len(form.WantedCorrections)
corrections, _, err := uc.zoneCorrector.ListZoneCorrections(provider, domain, records)
if err != nil {
return nil, happydns.InternalError{
Err: fmt.Errorf("unable to compute domain corrections: %w", err),
}
}
var errs error
corrections:
for i, cr := range corrections {
for ic, wc := range form.WantedCorrections {
if wc.Equals(cr.Id) {
log.Printf("%s: apply correction: %s", domain.DomainName, cr.Msg)
corrErr := cr.F()
if corrErr != nil {
log.Printf("%s: unable to apply correction: %s", domain.DomainName, corrErr.Error())
uc.appendDomainLog.AppendDomainLog(domain, happydns.NewDomainLog(user, happydns.LOG_ERR, fmt.Sprintf("Failed record update (%s): %s", cr.Msg, corrErr.Error())))
errs = errors.Join(errs, fmt.Errorf("%s: %w", cr.Msg, corrErr))
// Stop the zone update if we didn't change it yet
if i == 0 {
break corrections
}
} else {
form.WantedCorrections = append(form.WantedCorrections[:ic], form.WantedCorrections[ic+1:]...)
}
break
// Step 4: Execute all corrections.
appliedCount := 0
for _, cr := range executableCorrections {
log.Printf("%s: apply correction: %s", domain.DomainName, cr.Msg)
if corrErr := cr.F(); corrErr != nil {
log.Printf("%s: unable to apply correction: %s", domain.DomainName, corrErr.Error())
if logErr := uc.appendDomainLog.AppendDomainLog(domain, happydns.NewDomainLog(user, happydns.LOG_ERR, fmt.Sprintf("Failed record update (%s): %s", cr.Msg, corrErr.Error()))); logErr != nil {
log.Printf("unable to append domain log for %s: %s", domain.DomainName, logErr.Error())
}
if appliedCount == 0 {
return nil, happydns.ValidationError{Msg: fmt.Sprintf("unable to apply correction: %s", corrErr.Error())}
}
if logErr := uc.appendDomainLog.AppendDomainLog(domain, happydns.NewDomainLog(user, happydns.LOG_ERR, fmt.Sprintf("Failed zone publishing (%s): %d of %d corrections applied, errors occurred.", zone.Id.String(), appliedCount, len(executableCorrections)))); logErr != nil {
log.Printf("unable to append domain log for %s: %s", domain.DomainName, logErr.Error())
}
return nil, happydns.ValidationError{Msg: fmt.Sprintf("unable to update the zone (%d of %d corrections applied): %s", appliedCount, len(executableCorrections), corrErr.Error())}
}
appliedCount++
}
if errs != nil {
uc.appendDomainLog.AppendDomainLog(domain, happydns.NewDomainLog(user, happydns.LOG_ERR, fmt.Sprintf("Failed zone publishing (%s): %d corrections were not applied due to errors.", zone.Id.String(), nbcorrections)))
return nil, happydns.ValidationError{Msg: fmt.Sprintf("unable to update the zone: %s", errs.Error())}
} else if len(form.WantedCorrections) > 0 {
uc.appendDomainLog.AppendDomainLog(domain, happydns.NewDomainLog(user, happydns.LOG_ERR, fmt.Sprintf("Failed zone publishing (%s): %d corrections were not applied.", zone.Id.String(), nbcorrections)))
return nil, happydns.ValidationError{Msg: fmt.Sprintf("unable to perform the following changes: %s", form.WantedCorrections)}
if logErr := uc.appendDomainLog.AppendDomainLog(domain, happydns.NewDomainLog(user, happydns.LOG_ACK, fmt.Sprintf("Zone published (%s), %d corrections applied with success", zone.Id.String(), appliedCount))); logErr != nil {
log.Printf("unable to append domain log for %s: %s", domain.DomainName, logErr.Error())
}
uc.appendDomainLog.AppendDomainLog(domain, happydns.NewDomainLog(user, happydns.LOG_ACK, fmt.Sprintf("Zone published (%s), %d corrections applied with success", zone.Id.String(), nbcorrections)))
// Create a new zone in history for futher updates
newZone := zone.DerivateNew()
err = uc.zoneCreator.Create(newZone)
// Step 5: Create a published snapshot zone from target records.
services, defaultTTL, err := svcs.AnalyzeZone(domain.DomainName, targetRecords)
if err != nil {
return nil, happydns.InternalError{
Err: fmt.Errorf("unable to CreateZone: %w", err),
UserMessage: "Sorry, we are unable to create the zone now.",
Err: fmt.Errorf("unable to analyze target zone: %w", err),
UserMessage: "Sorry, we are unable to analyze the published zone.",
}
}
// Carry over metadata from WIP zone.
if zone.Services != nil {
zoneUC.ReassociateMetadata(zone.Services, services, domain.DomainName, defaultTTL)
}
// Also carry over metadata from the previous published zone if available.
if len(domain.ZoneHistory) > 1 {
prevZone, prevErr := uc.zoneGetter.Get(domain.ZoneHistory[1])
if prevErr != nil {
log.Printf("ReassociateMetadata: unable to load previous zone %s: %s (metadata will not be transferred)", domain.ZoneHistory[1], prevErr)
} else {
zoneUC.ReassociateMetadata(prevZone.Services, services, domain.DomainName, defaultTTL)
}
}
now := uc.clock()
snapshot := &happydns.Zone{
ZoneMeta: happydns.ZoneMeta{
IdAuthor: user.Id,
DefaultTTL: defaultTTL,
LastModified: now,
CommitMsg: &form.CommitMsg,
CommitDate: &now,
Published: &now,
ParentZone: &zone.ZoneMeta.Id,
},
Services: services,
}
err = uc.zoneCreator.Create(snapshot)
if err != nil {
return nil, happydns.InternalError{
Err: fmt.Errorf("unable to CreateZone for published snapshot: %w", err),
UserMessage: "Sorry, we are unable to create the published zone snapshot.",
}
}
// Step 6: Insert snapshot at ZoneHistory[1] (after WIP at position 0).
err = uc.domainUpdater.Update(domain.Id, user, func(domain *happydns.Domain) {
domain.ZoneHistory = append(
[]happydns.Identifier{newZone.Id}, domain.ZoneHistory...)
if len(domain.ZoneHistory) == 0 {
domain.ZoneHistory = []happydns.Identifier{snapshot.Id}
} else {
newHistory := make([]happydns.Identifier, 0, len(domain.ZoneHistory)+1)
newHistory = append(newHistory, domain.ZoneHistory[0])
newHistory = append(newHistory, snapshot.Id)
newHistory = append(newHistory, domain.ZoneHistory[1:]...)
domain.ZoneHistory = newHistory
}
})
if err != nil {
return nil, happydns.InternalError{
Err: fmt.Errorf("unable to UpdateDomain: %w", err),
UserMessage: "Sorry, we are unable to create the zone now.",
UserMessage: "Sorry, we are unable to update the domain history now.",
}
}
// Commit changes in previous zone
err = uc.zoneUpdater.Update(zone.ZoneMeta.Id, func(zone *happydns.Zone) {
now := time.Now()
zone.ZoneMeta.IdAuthor = user.Id
zone.CommitMsg = &form.CommitMsg
zone.ZoneMeta.CommitDate = &now
zone.ZoneMeta.Published = &now
zone.LastModified = time.Now()
})
if err != nil {
return nil, happydns.InternalError{
Err: fmt.Errorf("unable to UpdateZone: %w", err),
UserMessage: "Sorry, we are unable to create the zone now.",
}
}
return newZone, nil
}
func (uc *ZoneCorrectionApplierUsecase) List(user *happydns.User, domain *happydns.Domain, zone *happydns.Zone) ([]*happydns.Correction, int, error) {
provider, err := uc.providerService.GetUserProvider(user, domain.ProviderId)
if err != nil {
return nil, 0, err
}
records, err := uc.listRecords.List(domain, zone)
if err != nil {
return nil, 0, err
}
return uc.zoneCorrector.ListZoneCorrections(provider, domain, records)
return snapshot, nil
}

View file

@ -0,0 +1,102 @@
// 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
package orchestrator
import (
"context"
adapter "git.happydns.org/happyDomain/internal/adapters"
zoneUC "git.happydns.org/happyDomain/internal/usecase/zone"
"git.happydns.org/happyDomain/model"
)
// ZoneCorrectionListerUsecase computes the list of corrections needed to
// synchronize a zone's desired state with the records currently published by
// the provider. It fetches provider records, expands the WIP zone to records,
// and computes a local diff without executable closures.
type ZoneCorrectionListerUsecase struct {
providerService ProviderGetter
listRecords *zoneUC.ListRecordsUsecase
zoneCorrector ZoneCorrector
zoneRetriever ZoneRetriever
}
// NewZoneCorrectionListerUsecase creates a ZoneCorrectionListerUsecase with
// the given provider getter, record lister, zone corrector, and zone retriever.
func NewZoneCorrectionListerUsecase(
providerService ProviderGetter,
listRecords *zoneUC.ListRecordsUsecase,
zoneCorrector ZoneCorrector,
zoneRetriever ZoneRetriever,
) *ZoneCorrectionListerUsecase {
return &ZoneCorrectionListerUsecase{
providerService: providerService,
listRecords: listRecords,
zoneCorrector: zoneCorrector,
zoneRetriever: zoneRetriever,
}
}
// listWithRecords is the internal implementation that returns the corrections
// along with the provider and WIP records used to compute them.
func (uc *ZoneCorrectionListerUsecase) listWithRecords(
ctx context.Context,
user *happydns.User,
domain *happydns.Domain,
zone *happydns.Zone,
) ([]*happydns.Correction, []happydns.Record, []happydns.Record, int, error) {
provider, err := uc.providerService.GetUserProvider(user, domain.ProviderId)
if err != nil {
return nil, nil, nil, 0, err
}
providerRecords, err := uc.zoneRetriever.RetrieveZone(ctx, provider, domain.DomainName)
if err != nil {
return nil, nil, nil, 0, err
}
wipRecords, err := uc.listRecords.List(domain, zone)
if err != nil {
return nil, nil, nil, 0, err
}
corrections, nbDiffs, err := adapter.DNSControlDiffByRecord(providerRecords, wipRecords, domain.DomainName)
if err != nil {
return nil, nil, nil, nbDiffs, err
}
return corrections, providerRecords, wipRecords, nbDiffs, nil
}
// List returns the corrections required to bring the provider's live DNS
// records in line with the given zone. It fetches the current provider
// records, expands the zone into individual records, and computes the diff
// locally. The second return value is the total number of corrections.
func (uc *ZoneCorrectionListerUsecase) List(
ctx context.Context,
user *happydns.User,
domain *happydns.Domain,
zone *happydns.Zone,
) ([]*happydns.Correction, int, error) {
corrections, _, _, nbDiffs, err := uc.listWithRecords(ctx, user, domain, zone)
return corrections, nbDiffs, err
}

View file

@ -0,0 +1,188 @@
// 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
package orchestrator_test
import (
"context"
"errors"
"testing"
"git.happydns.org/happyDomain/internal/usecase/orchestrator"
serviceUC "git.happydns.org/happyDomain/internal/usecase/service"
zoneUC "git.happydns.org/happyDomain/internal/usecase/zone"
"git.happydns.org/happyDomain/model"
)
// mockProviderGetter implements ProviderGetter for testing.
type mockProviderGetter struct {
provider *happydns.Provider
err error
}
func (m *mockProviderGetter) GetUserProvider(_ *happydns.User, _ happydns.Identifier) (*happydns.Provider, error) {
return m.provider, m.err
}
// mockZoneCorrector implements ZoneCorrector for testing.
type mockZoneCorrector struct {
corrections []*happydns.Correction
nbDiff int
err error
}
func (m *mockZoneCorrector) ListZoneCorrections(_ context.Context, _ *happydns.Provider, _ *happydns.Domain, _ []happydns.Record) ([]*happydns.Correction, int, error) {
return m.corrections, m.nbDiff, m.err
}
// mockZoneRetriever implements ZoneRetriever for testing.
type mockZoneRetriever struct {
records []happydns.Record
err error
}
func (m *mockZoneRetriever) RetrieveZone(_ context.Context, _ *happydns.Provider, _ string) ([]happydns.Record, error) {
return m.records, m.err
}
func newTestListRecordsUsecase() *zoneUC.ListRecordsUsecase {
return zoneUC.NewListRecordsUsecase(serviceUC.NewListRecordsUsecase())
}
func TestZoneCorrectionLister_List_Success(t *testing.T) {
provider := &happydns.Provider{}
uc := orchestrator.NewZoneCorrectionListerUsecase(
&mockProviderGetter{provider: provider},
newTestListRecordsUsecase(),
&mockZoneCorrector{},
&mockZoneRetriever{records: nil},
)
user := &happydns.User{Id: happydns.Identifier([]byte("test-user"))}
domain := &happydns.Domain{
Id: happydns.Identifier([]byte("test-domain")),
ProviderId: happydns.Identifier([]byte("test-provider")),
DomainName: "example.com.",
}
zone := &happydns.Zone{
ZoneMeta: happydns.ZoneMeta{DefaultTTL: 3600},
Services: map[happydns.Subdomain][]*happydns.Service{},
}
got, nbDiff, err := uc.List(context.Background(), user, domain, zone)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if nbDiff != 0 {
t.Errorf("expected nbDiff=0, got %d", nbDiff)
}
if len(got) != 0 {
t.Errorf("expected 0 corrections, got %d", len(got))
}
}
func TestZoneCorrectionLister_List_ProviderError(t *testing.T) {
providerErr := errors.New("provider not found")
uc := orchestrator.NewZoneCorrectionListerUsecase(
&mockProviderGetter{err: providerErr},
newTestListRecordsUsecase(),
&mockZoneCorrector{},
&mockZoneRetriever{},
)
user := &happydns.User{Id: happydns.Identifier([]byte("test-user"))}
domain := &happydns.Domain{
ProviderId: happydns.Identifier([]byte("missing-provider")),
DomainName: "example.com.",
}
zone := &happydns.Zone{
ZoneMeta: happydns.ZoneMeta{DefaultTTL: 3600},
}
_, _, err := uc.List(context.Background(), user, domain, zone)
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, providerErr) {
t.Errorf("expected %v, got %v", providerErr, err)
}
}
func TestZoneCorrectionLister_List_RetrieveZoneError(t *testing.T) {
retrieveErr := errors.New("zone retrieval failed")
uc := orchestrator.NewZoneCorrectionListerUsecase(
&mockProviderGetter{provider: &happydns.Provider{}},
newTestListRecordsUsecase(),
&mockZoneCorrector{},
&mockZoneRetriever{err: retrieveErr},
)
user := &happydns.User{Id: happydns.Identifier([]byte("test-user"))}
domain := &happydns.Domain{
ProviderId: happydns.Identifier([]byte("test-provider")),
DomainName: "example.com.",
}
zone := &happydns.Zone{
ZoneMeta: happydns.ZoneMeta{DefaultTTL: 3600},
Services: map[happydns.Subdomain][]*happydns.Service{},
}
_, _, err := uc.List(context.Background(), user, domain, zone)
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, retrieveErr) {
t.Errorf("expected %v, got %v", retrieveErr, err)
}
}
func TestZoneCorrectionLister_List_NoCorrections(t *testing.T) {
uc := orchestrator.NewZoneCorrectionListerUsecase(
&mockProviderGetter{provider: &happydns.Provider{}},
newTestListRecordsUsecase(),
&mockZoneCorrector{corrections: nil, nbDiff: 0},
&mockZoneRetriever{records: nil},
)
user := &happydns.User{Id: happydns.Identifier([]byte("test-user"))}
domain := &happydns.Domain{
ProviderId: happydns.Identifier([]byte("test-provider")),
DomainName: "example.com.",
}
zone := &happydns.Zone{
ZoneMeta: happydns.ZoneMeta{DefaultTTL: 3600},
Services: map[happydns.Subdomain][]*happydns.Service{},
}
got, nbDiff, err := uc.List(context.Background(), user, domain, zone)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if nbDiff != 0 {
t.Errorf("expected nbDiff=0, got %d", nbDiff)
}
if len(got) != 0 {
t.Errorf("expected 0 corrections, got %d", len(got))
}
}

View file

@ -23,6 +23,7 @@ package orchestrator
import (
"fmt"
"log"
"time"
zoneUC "git.happydns.org/happyDomain/internal/usecase/zone"
@ -30,24 +31,43 @@ import (
"git.happydns.org/happyDomain/services"
)
// ZoneImporterUsecase converts a flat slice of DNS records into a structured
// happyDomain zone, preserving metadata from the previous zone when available,
// and persists the result as the newest entry in the domain's zone history.
type ZoneImporterUsecase struct {
domainUpdater DomainUpdater
zoneCreator *zoneUC.CreateZoneUsecase
zoneGetter *zoneUC.GetZoneUsecase
}
func NewZoneImporterUsecase(domainUpdater DomainUpdater, zoneCreator *zoneUC.CreateZoneUsecase) *ZoneImporterUsecase {
// NewZoneImporterUsecase creates a ZoneImporterUsecase with the given domain
// updater, zone creator, and zone getter.
func NewZoneImporterUsecase(domainUpdater DomainUpdater, zoneCreator *zoneUC.CreateZoneUsecase, zoneGetter *zoneUC.GetZoneUsecase) *ZoneImporterUsecase {
return &ZoneImporterUsecase{
domainUpdater: domainUpdater,
zoneCreator: zoneCreator,
zoneGetter: zoneGetter,
}
}
// Import analyzes rrs into services, optionally carries over metadata from the
// domain's most recent zone, persists the new zone, and prepends its ID to the
// domain's history. Returns the created zone or an error.
func (uc *ZoneImporterUsecase) Import(user *happydns.User, domain *happydns.Domain, rrs []happydns.Record) (*happydns.Zone, error) {
services, defaultTTL, err := svcs.AnalyzeZone(domain.DomainName, rrs)
if err != nil {
return nil, happydns.ValidationError{Msg: fmt.Sprintf("unable to perform the analysis of your zone: %s", err.Error())}
}
if len(domain.ZoneHistory) > 0 {
prevZone, err := uc.zoneGetter.Get(domain.ZoneHistory[0])
if err != nil {
log.Printf("ReassociateMetadata: unable to load previous zone %s: %s (metadata will not be transferred)", domain.ZoneHistory[0], err)
} else {
zoneUC.ReassociateMetadata(prevZone.Services, services, domain.DomainName, defaultTTL)
}
}
now := time.Now()
commit := fmt.Sprintf("Initial zone fetch from %s", domain.DomainName)
if len(domain.ZoneHistory) > 0 {

View file

@ -0,0 +1,30 @@
// 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
// Package provider implements the use cases for DNS provider management.
// Service handles creation, retrieval, update, and deletion of providers,
// with ownership enforcement (a user may only access their own providers).
// RestrictedService wraps Service and enforces the DisableProviders
// configuration flag. Zone and domain operations (record retrieval,
// correction listing, domain existence checking) are provided as separate
// methods so they can be consumed by higher-level orchestration use cases
// without circular imports.
package provider

View file

@ -29,9 +29,9 @@ import (
// CreateDomainOnProvider creates a domain on the given provider.
func (s *Service) CreateDomainOnProvider(provider *happydns.Provider, fqdn string) error {
p, err := provider.InstantiateProvider()
p, err := instantiate(provider)
if err != nil {
return fmt.Errorf("unable to instantiate the provider: %w", err)
return err
}
if !p.CanCreateDomain() {
@ -41,20 +41,11 @@ func (s *Service) CreateDomainOnProvider(provider *happydns.Provider, fqdn strin
return p.CreateDomain(fqdn)
}
// CreateDomainOnProvider for RestrictedService enforces configuration restrictions.
func (s *RestrictedService) CreateDomainOnProvider(provider *happydns.Provider, fqdn string) error {
if s.config.DisableProviders {
return happydns.ForbiddenError{Msg: "cannot create domain on provider as DisableProviders parameter is set."}
}
return s.Service.CreateDomainOnProvider(provider, fqdn)
}
// ListHostedDomains lists all domains hosted on the given provider.
func (s *Service) ListHostedDomains(provider *happydns.Provider) ([]string, error) {
p, err := provider.InstantiateProvider()
p, err := instantiate(provider)
if err != nil {
return nil, fmt.Errorf("unable to instantiate the provider: %w", err)
return nil, err
}
if !p.CanListZones() {
@ -66,9 +57,9 @@ func (s *Service) ListHostedDomains(provider *happydns.Provider) ([]string, erro
// TestDomainExistence tests whether a domain exists on the given provider.
func (s *Service) TestDomainExistence(provider *happydns.Provider, name string) error {
instance, err := provider.InstantiateProvider()
instance, err := instantiate(provider)
if err != nil {
return fmt.Errorf("unable to instantiate provider: %w", err)
return err
}
_, err = instance.GetZoneRecords(name)

View file

@ -22,6 +22,7 @@
package provider
import (
"context"
"encoding/json"
"fmt"
@ -29,23 +30,24 @@ import (
"git.happydns.org/happyDomain/providers"
)
// Service handles CRUD operations on DNS providers, with ownership enforcement.
type Service struct {
store ProviderStorage
validator ProviderValidator
}
func NewService(store ProviderStorage) *Service {
// NewService creates a new provider Service. If validator is nil,
// the DefaultProviderValidator is used.
func NewService(store ProviderStorage, validator ProviderValidator) *Service {
if validator == nil {
validator = &DefaultProviderValidator{}
}
return &Service{
store: store,
validator: &DefaultProviderValidator{},
validator: validator,
}
}
// SetValidator allows replacing the validator (useful for testing).
func (s *Service) SetValidator(v ProviderValidator) {
s.validator = v
}
// ParseProvider converts a ProviderMessage to a Provider.
func ParseProvider(msg *happydns.ProviderMessage) (p *happydns.Provider, err error) {
p = &happydns.Provider{}
@ -60,6 +62,15 @@ func ParseProvider(msg *happydns.ProviderMessage) (p *happydns.Provider, err err
return
}
// instantiate is a helper that instantiates a provider and wraps errors consistently.
func instantiate(p *happydns.Provider) (happydns.ProviderActuator, error) {
instance, err := p.InstantiateProvider()
if err != nil {
return nil, fmt.Errorf("unable to instantiate provider: %w", err)
}
return instance, nil
}
// CreateProvider creates a new provider for the given user.
func (s *Service) CreateProvider(user *happydns.User, msg *happydns.ProviderMessage) (*happydns.Provider, error) {
provider, err := ParseProvider(msg)
@ -121,7 +132,10 @@ func (s *Service) GetUserProviderMeta(user *happydns.User, providerID happydns.I
func (s *Service) ListUserProviders(user *happydns.User) ([]*happydns.ProviderMeta, error) {
items, err := s.store.ListProviders(user)
if err != nil {
return nil, fmt.Errorf("list providers failed: %w", err)
return nil, happydns.InternalError{
Err: fmt.Errorf("failed to list providers: %w", err),
UserMessage: "Sorry, we are currently unable to list your providers. Please try again later.",
}
}
metas := make([]*happydns.ProviderMeta, 0, len(items))
@ -175,22 +189,11 @@ func (s *Service) UpdateProviderFromMessage(providerID happydns.Identifier, user
// DeleteProvider deletes a provider for the given user.
func (s *Service) DeleteProvider(user *happydns.User, providerID happydns.Identifier) error {
// TODO: Find another way to avoid import cycle
// We should verify that no domains are using this provider before deleting
/*domains, err := s.listDomains.List(user)
if err != nil {
return happydns.InternalError{
Err: fmt.Errorf("failed to list domains: %w", err),
UserMessage: "Sorry, we are currently unable to perform this action. Please try again later.",
}
// Verify ownership before deleting
if _, err := s.getUserProvider(user, providerID); err != nil {
return err
}
for _, d := range domains {
if d.ProviderId.Equals(providerID) {
return fmt.Errorf("You cannot delete this provider because it is still used by: %s", d.DomainName)
}
}*/
if err := s.store.DeleteProvider(providerID); err != nil {
return happydns.InternalError{
Err: fmt.Errorf("failed to delete provider %s: %w", providerID.String(), err),
@ -201,48 +204,90 @@ func (s *Service) DeleteProvider(user *happydns.User, providerID happydns.Identi
return nil
}
// RestrictedService wraps Service with configuration-based restrictions.
// RestrictedService wraps a ProviderUsecase with configuration-based restrictions.
type RestrictedService struct {
Service
inner happydns.ProviderUsecase
config *happydns.Options
}
// NewRestrictedService creates a RestrictedService backed by the given configuration and storage.
func NewRestrictedService(cfg *happydns.Options, store ProviderStorage) *RestrictedService {
s := NewService(store)
return &RestrictedService{
*s,
cfg,
inner: NewService(store, nil),
config: cfg,
}
}
// CreateProvider refuses the operation when DisableProviders is set, otherwise delegates to Service.
func (s *RestrictedService) CreateProvider(user *happydns.User, msg *happydns.ProviderMessage) (*happydns.Provider, error) {
if s.config.DisableProviders {
return nil, happydns.ForbiddenError{Msg: "cannot add provider as DisableProviders parameter is set."}
}
return s.Service.CreateProvider(user, msg)
return s.inner.CreateProvider(user, msg)
}
// DeleteProvider refuses the operation when DisableProviders is set, otherwise delegates to Service.
func (s *RestrictedService) DeleteProvider(user *happydns.User, providerID happydns.Identifier) error {
if s.config.DisableProviders {
return happydns.ForbiddenError{Msg: "cannot delete provider as DisableProviders parameter is set."}
}
return s.Service.DeleteProvider(user, providerID)
return s.inner.DeleteProvider(user, providerID)
}
// UpdateProvider refuses the operation when DisableProviders is set, otherwise delegates to Service.
func (s *RestrictedService) UpdateProvider(providerID happydns.Identifier, user *happydns.User, updateFn func(*happydns.Provider)) error {
if s.config.DisableProviders {
return happydns.ForbiddenError{Msg: "cannot update provider as DisableProviders parameter is set."}
}
return s.Service.UpdateProvider(providerID, user, updateFn)
return s.inner.UpdateProvider(providerID, user, updateFn)
}
// UpdateProviderFromMessage refuses the operation when DisableProviders is set, otherwise delegates to Service.
func (s *RestrictedService) UpdateProviderFromMessage(providerID happydns.Identifier, user *happydns.User, p *happydns.ProviderMessage) error {
if s.config.DisableProviders {
return happydns.ForbiddenError{Msg: "cannot update provider as DisableProviders parameter is set."}
}
return s.Service.UpdateProviderFromMessage(providerID, user, p)
return s.inner.UpdateProviderFromMessage(providerID, user, p)
}
func (s *RestrictedService) CreateDomainOnProvider(provider *happydns.Provider, fqdn string) error {
if s.config.DisableProviders {
return happydns.ForbiddenError{Msg: "cannot create domain on provider as DisableProviders parameter is set."}
}
return s.inner.CreateDomainOnProvider(provider, fqdn)
}
// Read-only operations delegate directly.
func (s *RestrictedService) GetUserProvider(user *happydns.User, providerID happydns.Identifier) (*happydns.Provider, error) {
return s.inner.GetUserProvider(user, providerID)
}
func (s *RestrictedService) GetUserProviderMeta(user *happydns.User, providerID happydns.Identifier) (*happydns.ProviderMeta, error) {
return s.inner.GetUserProviderMeta(user, providerID)
}
func (s *RestrictedService) ListUserProviders(user *happydns.User) ([]*happydns.ProviderMeta, error) {
return s.inner.ListUserProviders(user)
}
func (s *RestrictedService) ListHostedDomains(provider *happydns.Provider) ([]string, error) {
return s.inner.ListHostedDomains(provider)
}
func (s *RestrictedService) ListZoneCorrections(ctx context.Context, provider *happydns.Provider, domain *happydns.Domain, records []happydns.Record) ([]*happydns.Correction, int, error) {
return s.inner.ListZoneCorrections(ctx, provider, domain, records)
}
func (s *RestrictedService) RetrieveZone(ctx context.Context, provider *happydns.Provider, name string) ([]happydns.Record, error) {
return s.inner.RetrieveZone(ctx, provider, name)
}
func (s *RestrictedService) TestDomainExistence(provider *happydns.Provider, name string) error {
return s.inner.TestDomainExistence(provider, name)
}

View file

@ -74,12 +74,14 @@ func (v *mockValidator) Validate(p *happydns.Provider) error {
return nil
}
func Test_CreateProvider(t *testing.T) {
func newTestService(t *testing.T) (*provider.Service, storage.Storage) {
mem, _ := inmemory.NewInMemoryStorage()
db, _ := kv.NewKVDatabase(mem)
providerService := provider.NewService(db)
// Replace validator with mock to avoid actual DNS validation
providerService.SetValidator(&mockValidator{})
return provider.NewService(db, &mockValidator{}), db
}
func Test_CreateProvider(t *testing.T) {
providerService, db := newTestService(t)
user := createTestUser(t, db, "test@example.com")
msg := createTestProviderMessage(t, "DDNSServer", "Test DDNS Provider")
@ -110,10 +112,7 @@ func Test_CreateProvider(t *testing.T) {
}
func Test_GetUserProvider(t *testing.T) {
mem, _ := inmemory.NewInMemoryStorage()
db, _ := kv.NewKVDatabase(mem)
providerService := provider.NewService(db)
providerService.SetValidator(&mockValidator{})
providerService, db := newTestService(t)
user := createTestUser(t, db, "test@example.com")
@ -139,10 +138,7 @@ func Test_GetUserProvider(t *testing.T) {
}
func Test_GetUserProvider_WrongUser(t *testing.T) {
mem, _ := inmemory.NewInMemoryStorage()
db, _ := kv.NewKVDatabase(mem)
providerService := provider.NewService(db)
providerService.SetValidator(&mockValidator{})
providerService, db := newTestService(t)
user1 := createTestUser(t, db, "user1@example.com")
user2 := createTestUser(t, db, "user2@example.com")
@ -165,9 +161,7 @@ func Test_GetUserProvider_WrongUser(t *testing.T) {
}
func Test_GetUserProvider_NotFound(t *testing.T) {
mem, _ := inmemory.NewInMemoryStorage()
db, _ := kv.NewKVDatabase(mem)
providerService := provider.NewService(db)
providerService, db := newTestService(t)
user := createTestUser(t, db, "test@example.com")
@ -182,10 +176,7 @@ func Test_GetUserProvider_NotFound(t *testing.T) {
}
func Test_GetUserProviderMeta(t *testing.T) {
mem, _ := inmemory.NewInMemoryStorage()
db, _ := kv.NewKVDatabase(mem)
providerService := provider.NewService(db)
providerService.SetValidator(&mockValidator{})
providerService, db := newTestService(t)
user := createTestUser(t, db, "test@example.com")
@ -211,10 +202,7 @@ func Test_GetUserProviderMeta(t *testing.T) {
}
func Test_ListUserProviders(t *testing.T) {
mem, _ := inmemory.NewInMemoryStorage()
db, _ := kv.NewKVDatabase(mem)
providerService := provider.NewService(db)
providerService.SetValidator(&mockValidator{})
providerService, db := newTestService(t)
user := createTestUser(t, db, "test@example.com")
@ -244,10 +232,7 @@ func Test_ListUserProviders(t *testing.T) {
}
func Test_ListUserProviders_MultipleUsers(t *testing.T) {
mem, _ := inmemory.NewInMemoryStorage()
db, _ := kv.NewKVDatabase(mem)
providerService := provider.NewService(db)
providerService.SetValidator(&mockValidator{})
providerService, db := newTestService(t)
user1 := createTestUser(t, db, "user1@example.com")
user2 := createTestUser(t, db, "user2@example.com")
@ -288,10 +273,7 @@ func Test_ListUserProviders_MultipleUsers(t *testing.T) {
}
func Test_UpdateProvider(t *testing.T) {
mem, _ := inmemory.NewInMemoryStorage()
db, _ := kv.NewKVDatabase(mem)
providerService := provider.NewService(db)
providerService.SetValidator(&mockValidator{})
providerService, db := newTestService(t)
user := createTestUser(t, db, "test@example.com")
@ -321,10 +303,7 @@ func Test_UpdateProvider(t *testing.T) {
}
func Test_UpdateProvider_PreventIdChange(t *testing.T) {
mem, _ := inmemory.NewInMemoryStorage()
db, _ := kv.NewKVDatabase(mem)
providerService := provider.NewService(db)
providerService.SetValidator(&mockValidator{})
providerService, db := newTestService(t)
user := createTestUser(t, db, "test@example.com")
@ -349,10 +328,7 @@ func Test_UpdateProvider_PreventIdChange(t *testing.T) {
}
func Test_UpdateProvider_WrongUser(t *testing.T) {
mem, _ := inmemory.NewInMemoryStorage()
db, _ := kv.NewKVDatabase(mem)
providerService := provider.NewService(db)
providerService.SetValidator(&mockValidator{})
providerService, db := newTestService(t)
user1 := createTestUser(t, db, "user1@example.com")
user2 := createTestUser(t, db, "user2@example.com")
@ -374,10 +350,7 @@ func Test_UpdateProvider_WrongUser(t *testing.T) {
}
func Test_DeleteProvider(t *testing.T) {
mem, _ := inmemory.NewInMemoryStorage()
db, _ := kv.NewKVDatabase(mem)
providerService := provider.NewService(db)
providerService.SetValidator(&mockValidator{})
providerService, db := newTestService(t)
user := createTestUser(t, db, "test@example.com")
@ -404,6 +377,35 @@ func Test_DeleteProvider(t *testing.T) {
}
}
func Test_DeleteProvider_WrongUser(t *testing.T) {
providerService, db := newTestService(t)
user1 := createTestUser(t, db, "user1@example.com")
user2 := createTestUser(t, db, "user2@example.com")
// Create a provider for user1
msg := createTestProviderMessage(t, "DDNSServer", "User1 Provider")
createdProvider, err := providerService.CreateProvider(user1, msg)
if err != nil {
t.Fatalf("unexpected error creating provider: %v", err)
}
// Try to delete the provider as user2
err = providerService.DeleteProvider(user2, createdProvider.Id)
if err == nil {
t.Error("expected error when deleting another user's provider")
}
if err != happydns.ErrProviderNotFound {
t.Errorf("expected ErrProviderNotFound, got %v", err)
}
// Verify the provider still exists for user1
_, err = providerService.GetUserProvider(user1, createdProvider.Id)
if err != nil {
t.Errorf("provider should still exist for user1, got error: %v", err)
}
}
func Test_ParseProvider(t *testing.T) {
msg := createTestProviderMessage(t, "DDNSServer", "Test Parse")
@ -462,8 +464,7 @@ func Test_RestrictedService_UpdateProvider_Disabled(t *testing.T) {
db, _ := kv.NewKVDatabase(mem)
// First create a provider without restrictions
unrestricted := provider.NewService(db)
unrestricted.SetValidator(&mockValidator{})
unrestricted := provider.NewService(db, &mockValidator{})
user := createTestUser(t, db, "test@example.com")
msg := createTestProviderMessage(t, "DDNSServer", "Test Provider")
createdProvider, err := unrestricted.CreateProvider(user, msg)
@ -493,8 +494,7 @@ func Test_RestrictedService_DeleteProvider_Disabled(t *testing.T) {
db, _ := kv.NewKVDatabase(mem)
// First create a provider without restrictions
unrestricted := provider.NewService(db)
unrestricted.SetValidator(&mockValidator{})
unrestricted := provider.NewService(db, &mockValidator{})
user := createTestUser(t, db, "test@example.com")
msg := createTestProviderMessage(t, "DDNSServer", "Test Provider")
createdProvider, err := unrestricted.CreateProvider(user, msg)

View file

@ -25,6 +25,7 @@ import (
"git.happydns.org/happyDomain/model"
)
// ProviderStorage is the persistence interface required by the provider use cases.
type ProviderStorage interface {
// ListAllProviders retrieves the list of known Providers.
ListAllProviders() (happydns.Iterator[happydns.ProviderMessage], error)

View file

@ -27,12 +27,15 @@ import (
"git.happydns.org/happyDomain/model"
)
// ProviderValidator verifies that a provider configuration is functional before it is persisted.
type ProviderValidator interface {
Validate(*happydns.Provider) error
}
// DefaultProviderValidator instantiates the provider and, when zone listing is supported, performs a live check.
type DefaultProviderValidator struct{}
// Validate instantiates the provider and, if it supports zone listing, calls ListZones to confirm credentials are valid.
func (v *DefaultProviderValidator) Validate(p *happydns.Provider) error {
instance, err := p.InstantiateProvider()
if err != nil {

View file

@ -22,26 +22,26 @@
package provider
import (
"fmt"
"context"
"git.happydns.org/happyDomain/model"
)
// RetrieveZone retrieves the current zone records for the given domain from the provider.
func (s *Service) RetrieveZone(provider *happydns.Provider, name string) ([]happydns.Record, error) {
instance, err := provider.InstantiateProvider()
func (s *Service) RetrieveZone(_ context.Context, provider *happydns.Provider, name string) ([]happydns.Record, error) {
instance, err := instantiate(provider)
if err != nil {
return nil, fmt.Errorf("unable to instantiate provider: %w", err)
return nil, err
}
return instance.GetZoneRecords(name)
}
// ListZoneCorrections lists the corrections needed to synchronize the zone with the given records.
func (s *Service) ListZoneCorrections(provider *happydns.Provider, domain *happydns.Domain, records []happydns.Record) ([]*happydns.Correction, int, error) {
instance, err := provider.InstantiateProvider()
func (s *Service) ListZoneCorrections(_ context.Context, provider *happydns.Provider, domain *happydns.Domain, records []happydns.Record) ([]*happydns.Correction, int, error) {
instance, err := instantiate(provider)
if err != nil {
return nil, 0, fmt.Errorf("unable to instantiate provider: %w", err)
return nil, 0, err
}
return instance.GetZoneCorrections(domain.DomainName, records)

View file

@ -22,24 +22,22 @@
package usecase
import (
"encoding/json"
"fmt"
"git.happydns.org/happyDomain/internal/forms"
"git.happydns.org/happyDomain/internal/usecase/provider"
"git.happydns.org/happyDomain/model"
)
type providerSettingsUsecase struct {
config *happydns.Options
providerService happydns.ProviderUsecase
store provider.ProviderStorage
}
func NewProviderSettingsUsecase(cfg *happydns.Options, ps happydns.ProviderUsecase, store provider.ProviderStorage) happydns.ProviderSettingsUsecase {
func NewProviderSettingsUsecase(cfg *happydns.Options, ps happydns.ProviderUsecase) happydns.ProviderSettingsUsecase {
return &providerSettingsUsecase{
config: cfg,
providerService: ps,
store: store,
}
}
@ -55,56 +53,43 @@ func (psu *providerSettingsUsecase) NextProviderSettingsState(state *happydns.Pr
return nil, nil, happydns.ForbiddenError{Msg: "cannot change provider settings as DisableProviders parameter is set."}
}
p, err := state.ProviderBody.InstantiateProvider()
providerJSON, err := json.Marshal(state.ProviderBody)
if err != nil {
return nil, nil, happydns.ValidationError{Msg: fmt.Sprintf("unable to instantiate provider: %s", err.Error())}
return nil, nil, happydns.InternalError{
Err: fmt.Errorf("unable to marshal provider body: %w", err),
UserMessage: happydns.TryAgainErr,
}
}
if p.CanListZones() {
if _, err = p.ListZones(); err != nil {
return nil, nil, happydns.ValidationError{Msg: fmt.Sprintf("unable to list provider's zones: %s", err.Error())}
}
msg := &happydns.ProviderMessage{
ProviderMeta: happydns.ProviderMeta{
Type: pType,
Comment: state.Name,
},
Provider: providerJSON,
}
if state.Id == nil {
provider := &happydns.Provider{
Provider: state.ProviderBody,
ProviderMeta: happydns.ProviderMeta{
Type: pType,
Owner: user.Id,
Comment: state.Name,
},
}
// Create a new Provider
err = psu.store.CreateProvider(provider)
// Create a new Provider via the service layer
provider, err := psu.providerService.CreateProvider(user, msg)
if err != nil {
return nil, nil, happydns.InternalError{
Err: fmt.Errorf("unable to CreateProvider: %w", err),
UserMessage: happydns.TryAgainErr,
}
return nil, nil, err
}
return provider, nil, nil
} else {
// Update an existing Provider
p, err := psu.providerService.GetUserProvider(user, *state.Id)
// Update an existing Provider via the service layer
err := psu.providerService.UpdateProviderFromMessage(*state.Id, user, msg)
if err != nil {
return nil, nil, happydns.NotFoundError{Msg: fmt.Sprintf("unable to retrieve the original provider: %s", err.Error())}
return nil, nil, err
}
newp := &happydns.Provider{
ProviderMeta: p.ProviderMeta,
Provider: state.ProviderBody,
}
err = psu.store.UpdateProvider(newp)
provider, err := psu.providerService.GetUserProvider(user, *state.Id)
if err != nil {
return nil, nil, happydns.InternalError{
Err: fmt.Errorf("unable to UpdateProvider: %w", err),
UserMessage: happydns.TryAgainErr,
}
return nil, nil, err
}
return newp, nil, nil
return provider, nil, nil
}
}

View file

@ -19,18 +19,32 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Package service implements use cases that operate on individual DNS services
// (the logical groupings of records within a zone subdomain). It provides:
// - ListRecordsUsecase expands a Service into its constituent DNS records.
// - SearchRecordUsecase locates the Service and subdomain that owns a given
// record within a Zone.
// - ValidateServiceUsecase verifies that a ServiceBody can generate at least
// one record and returns a SHA-1 hash of the resulting RDATA.
// - ParseService deserialises a ServiceMessage into a typed Service value.
//
// The Service facade wires these together and is the main entry point consumed
// by higher-level zone use cases.
package service
import (
"git.happydns.org/happyDomain/model"
)
// Service is the facade for all service-level use cases. Callers should use
// its methods rather than reaching into the embedded use-case structs directly.
type Service struct {
ListRecordsUC *ListRecordsUsecase
SearchRecordUC *SearchRecordUsecase
ValidateServiceUC *ValidateServiceUsecase
}
// NewServiceUsecases wires and returns a ready-to-use Service facade.
func NewServiceUsecases() *Service {
ListRecordsUC := NewListRecordsUsecase()
@ -41,10 +55,14 @@ func NewServiceUsecases() *Service {
}
}
// ListRecords expands the given service into its constituent DNS records,
// qualifying names relative to domain and applying the zone's default TTL.
func (s *Service) ListRecords(domain *happydns.Domain, zone *happydns.Zone, service *happydns.Service) ([]happydns.Record, error) {
return s.ListRecordsUC.List(service, domain.DomainName, zone.DefaultTTL)
}
// ValidateService verifies that body generates at least one DNS record and
// returns a SHA-1 hash of the resulting RDATA for change-detection purposes.
func (s *Service) ValidateService(body happydns.ServiceBody, subdomain happydns.Subdomain, origin happydns.Origin) ([]byte, error) {
return s.ValidateServiceUC.Validate(body, subdomain, origin)
}

View file

@ -26,12 +26,18 @@ import (
"git.happydns.org/happyDomain/model"
)
// ListRecordsUsecase expands a Service into its raw DNS records.
type ListRecordsUsecase struct{}
// NewListRecordsUsecase returns a new ListRecordsUsecase.
func NewListRecordsUsecase() *ListRecordsUsecase {
return &ListRecordsUsecase{}
}
// List generates the DNS records produced by svc. Record names are made
// absolute relative to origin and any record whose TTL is zero inherits
// defaultTTL. When svc.Ttl is non-zero it overrides defaultTTL for all
// records of this service.
func (uc *ListRecordsUsecase) List(svc *happydns.Service, origin string, defaultTTL uint32) ([]happydns.Record, error) {
if svc.Ttl != 0 {
defaultTTL = svc.Ttl

View file

@ -28,6 +28,9 @@ import (
"git.happydns.org/happyDomain/services"
)
// ParseService deserialises a ServiceMessage into a typed Service value.
// It looks up the concrete ServiceBody type by msg.Type, then JSON-decodes
// msg.Service into it.
func ParseService(msg *happydns.ServiceMessage) (svc *happydns.Service, err error) {
svc = &happydns.Service{}

View file

@ -27,16 +27,22 @@ import (
"git.happydns.org/happyDomain/model"
)
// SearchRecordUsecase locates the Service and subdomain that own a given DNS
// record within a Zone.
type SearchRecordUsecase struct {
serviceListRecordsUC *ListRecordsUsecase
}
// NewSearchRecordUsecase returns a SearchRecordUsecase backed by the provided
// ListRecordsUsecase.
func NewSearchRecordUsecase(serviceListRecordsUC *ListRecordsUsecase) *SearchRecordUsecase {
return &SearchRecordUsecase{
serviceListRecordsUC: serviceListRecordsUC,
}
}
// ExistsInService reports whether record is produced by svc. Two records are
// considered equal when their name, type, class, and RDATA all match.
func (uc *SearchRecordUsecase) ExistsInService(svc *happydns.Service, record happydns.Record) (bool, error) {
records, err := uc.serviceListRecordsUC.List(svc, "", 0)
if err != nil {
@ -55,6 +61,9 @@ func (uc *SearchRecordUsecase) ExistsInService(svc *happydns.Service, record hap
return false, nil
}
// Search scans every subdomain in zone and returns the first subdomain and
// Service that produce record. Returns an empty subdomain and nil service
// when no match is found.
func (uc *SearchRecordUsecase) Search(zone *happydns.Zone, record happydns.Record) (happydns.Subdomain, *happydns.Service, error) {
for dn, _ := range zone.Services {
svc, err := uc.SearchInSubdomain(zone, dn, record)
@ -66,6 +75,8 @@ func (uc *SearchRecordUsecase) Search(zone *happydns.Zone, record happydns.Recor
return "", nil, nil
}
// SearchInSubdomain looks for record among the services attached to subdomain
// in zone. Returns nil when subdomain does not exist or no service matches.
func (uc *SearchRecordUsecase) SearchInSubdomain(zone *happydns.Zone, subdomain happydns.Subdomain, record happydns.Record) (*happydns.Service, error) {
services, ok := zone.Services[subdomain]
if !ok {

View file

@ -25,6 +25,8 @@ import (
"git.happydns.org/happyDomain/model"
)
// ZoneUpdaterStorage is the storage interface required by use cases that need
// to persist changes to a Zone.
type ZoneUpdaterStorage interface {
// UpdateZone updates the fields of the given Zone.
UpdateZone(zone *happydns.Zone) error

View file

@ -29,12 +29,19 @@ import (
"git.happydns.org/happyDomain/model"
)
// ValidateServiceUsecase verifies that a ServiceBody can produce at least one
// DNS record and computes a SHA-1 fingerprint of the resulting RDATA.
type ValidateServiceUsecase struct{}
// NewValidateServiceUsecase returns a new ValidateServiceUsecase.
func NewValidateServiceUsecase() *ValidateServiceUsecase {
return &ValidateServiceUsecase{}
}
// Validate calls svc.GetRecords with the given subdomain and origin. It
// returns an error when no records are generated, otherwise it returns a
// SHA-1 hash of all record strings concatenated — suitable for change
// detection on the client side.
func (uc *ValidateServiceUsecase) Validate(svc happydns.ServiceBody, subdomain happydns.Subdomain, origin happydns.Origin) ([]byte, error) {
rrs, err := svc.GetRecords(string(subdomain), 0, string(origin))
if err != nil {

View file

@ -33,13 +33,19 @@ import (
"git.happydns.org/happyDomain/services"
)
// serviceSpecsUsecase implements happydns.ServiceSpecsUsecase, providing
// introspection into registered DNS services: listing them, retrieving their
// field specifications, and generating preview DNS records.
type serviceSpecsUsecase struct {
}
// NewServiceSpecsUsecase creates a new ServiceSpecsUsecase.
func NewServiceSpecsUsecase() happydns.ServiceSpecsUsecase {
return &serviceSpecsUsecase{}
}
// ListServices returns metadata (ServiceInfos) for every registered DNS service,
// keyed by service type identifier.
func (ssu *serviceSpecsUsecase) ListServices() map[string]happydns.ServiceInfos {
services := svcs.ListServices()
@ -51,6 +57,9 @@ func (ssu *serviceSpecsUsecase) ListServices() map[string]happydns.ServiceInfos
return ret
}
// GetServiceIcon returns the raw PNG icon bytes for the service identified by
// ssid (with or without the ".png" suffix). Returns NotFoundError if no icon
// is registered for that service.
func (ssu *serviceSpecsUsecase) GetServiceIcon(ssid string) ([]byte, error) {
cnt, ok := svcs.Icons[strings.TrimSuffix(ssid, ".png")]
if !ok {
@ -60,10 +69,18 @@ func (ssu *serviceSpecsUsecase) GetServiceIcon(ssid string) ([]byte, error) {
return cnt, nil
}
// GetServiceSpecs returns the field specifications for a service type,
// describing each configurable field with its type, label, constraints, and
// other UI metadata.
func (ssu *serviceSpecsUsecase) GetServiceSpecs(svctype reflect.Type) (*happydns.ServiceSpecs, error) {
return ssu.getSpecs(svctype)
}
// InitializeService returns a new instance of the service type populated with
// sensible default values. If the service implements ServiceInitializer its
// Initialize method is called; otherwise defaults are derived by reflection:
// slices of scalar types are pre-populated with one empty element, nested
// structs and DNS record types are recursively initialized.
func (ssu *serviceSpecsUsecase) InitializeService(svctype reflect.Type) (any, error) {
// Create a new instance of the service
svcPtr := reflect.New(svctype)
@ -110,6 +127,7 @@ func (ssu *serviceSpecsUsecase) InitializeService(svctype reflect.Type) (any, er
return svc, nil
}
// countSettableFields returns the number of exported, non-anonymous fields in v.
func (ssu *serviceSpecsUsecase) countSettableFields(v reflect.Value) int {
count := 0
for i := 0; i < v.NumField(); i++ {
@ -123,6 +141,10 @@ func (ssu *serviceSpecsUsecase) countSettableFields(v reflect.Value) int {
return count
}
// initializeStructFields recursively initializes exported fields of a struct
// value: slices become empty non-nil slices, maps become empty non-nil maps,
// DNS types are initialized via initializeDNSRecord, and nested structs are
// processed recursively.
func (ssu *serviceSpecsUsecase) initializeStructFields(v reflect.Value) {
for i := 0; i < v.NumField(); i++ {
field := v.Field(i)
@ -174,7 +196,9 @@ func (ssu *serviceSpecsUsecase) initializeStructFields(v reflect.Value) {
}
}
// isDNSType checks if a type is from the miekg/dns package or a happyDomain DNS abstraction
// isDNSType reports whether t is a DNS record type — either from the
// github.com/miekg/dns package or a happyDomain model type that embeds a
// dns.RR_Header field named "Hdr".
func (ssu *serviceSpecsUsecase) isDNSType(t reflect.Type) bool {
pkgPath := t.PkgPath()

View file

@ -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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
// Package session provides the business logic for managing user sessions in
// happyDomain. It exposes a [Service] that handles the full session lifecycle:
// creation, retrieval, update, and deletion, as well as bulk operations such as
// closing all sessions for a given user.
//
// The package defines the [SessionStorage] interface that any persistence
// backend must implement. A concrete implementation is injected at construction
// time via [NewService], keeping this layer free of storage concerns.
//
// Session identifiers are randomly generated, base32-encoded strings (see
// [NewSessionID]). Sessions carry an expiry timestamp and are automatically
// bound to a single user — cross-user access is rejected at the use-case level.
//
// Typical usage:
//
// svc := session.NewService(myStorageBackend)
//
// sess, err := svc.CreateUserSession(user, "browser login")
// // … store sess.Id in a cookie …
//
// sess, err = svc.GetUserSession(user, sessionID)
//
// err = svc.UpdateUserSession(user, sessionID, func(s *happydns.Session) {
// s.Description = "renamed"
// })
//
// err = svc.DeleteUserSession(user, sessionID)
//
// err = svc.CloseUserSessions(user) // invalidate all sessions at once
package session

View file

@ -25,7 +25,6 @@ import (
"encoding/base32"
"errors"
"fmt"
"strings"
"time"
"github.com/gorilla/securecookie"
@ -33,32 +32,29 @@ import (
"git.happydns.org/happyDomain/model"
)
// SESSION_MAX_DURATION is the lifetime assigned to every newly created session.
const SESSION_MAX_DURATION = 15 * 24 * time.Hour
// SESSION_RENEWAL_THRESHOLD is the remaining lifetime below which a session
// is automatically renewed to SESSION_MAX_DURATION on the next request.
const SESSION_RENEWAL_THRESHOLD = 7 * 24 * time.Hour
// Service handles all session-related operations.
// This consolidates what were previously separate usecase structs into a single service.
// Service handles all session-related operations for happyDomain users. It
// relies on a [SessionStorage] backend for persistence and enforces ownership
// checks so that one user can never read or modify another user's sessions.
type Service struct {
store SessionStorage
}
// NewService creates a new session service.
// This replaces the old NewSessionUsecases factory function.
// NewService creates a new session Service backed by the given store.
func NewService(store SessionStorage) *Service {
return &Service{store: store}
}
// NewSessionUsecases is a backward-compatible alias for NewService.
// Deprecated: Use NewService instead.
func NewSessionUsecases(store SessionStorage) *Service {
return NewService(store)
}
// CreateUserSession creates a new session for the given user.
// Replaces: CreateUserSessionUsecase.Create
// CreateUserSession creates and persists a new session for user. The session
// is assigned a random identifier, the current time as its issue date, and an
// expiry of [SESSION_MAX_DURATION] from now. description is a human-readable
// label that the user can use to identify the session (e.g. "browser login").
func (s *Service) CreateUserSession(user *happydns.User, description string) (*happydns.Session, error) {
sessid := NewSessionID()
@ -77,8 +73,9 @@ func (s *Service) CreateUserSession(user *happydns.User, description string) (*h
return newsession, nil
}
// GetUserSession retrieves a session for the given user.
// Replaces: GetUserSessionUsecase.Get
// GetUserSession retrieves the session identified by sessionID and verifies
// that it belongs to user. Returns [happydns.ErrSessionNotFound] if the
// session does not exist or belongs to a different user.
func (s *Service) GetUserSession(user *happydns.User, sessionID string) (*happydns.Session, error) {
session, err := s.store.GetSession(sessionID)
if err != nil {
@ -92,19 +89,21 @@ func (s *Service) GetUserSession(user *happydns.User, sessionID string) (*happyd
return session, nil
}
// ListUserSessions retrieves all sessions for the given user.
// Replaces: ListUserSessionsUsecase.List
// ListUserSessions returns all active sessions belonging to user.
func (s *Service) ListUserSessions(user *happydns.User) ([]*happydns.Session, error) {
return s.store.ListUserSessions(user.GetUserId())
}
// listUserSessionsInternal is a helper that accepts UserInfo interface.
// listUserSessionsInternal is like [Service.ListUserSessions] but accepts the
// broader [happydns.UserInfo] interface.
func (s *Service) listUserSessionsInternal(user happydns.UserInfo) ([]*happydns.Session, error) {
return s.store.ListUserSessions(user.GetUserId())
}
// UpdateUserSession updates a user's session using the provided update function.
// Replaces: UpdateUserSessionUsecase.Update
// UpdateUserSession applies updateFunc to the session identified by sessionID
// and persists the result. The session must belong to user; otherwise an error
// is returned. The function sets ModifiedOn automatically. Attempting to change
// the session ID inside updateFunc is rejected with an error.
func (s *Service) UpdateUserSession(
user *happydns.User,
sessionID string,
@ -129,8 +128,9 @@ func (s *Service) UpdateUserSession(
return nil
}
// DeleteUserSession deletes a specific session for the given user.
// Replaces: DeleteUserSessionUsecase.Delete
// DeleteUserSession removes the session identified by sessionID. The session
// must belong to user; an attempt to delete another user's session returns an
// error and leaves the session untouched.
func (s *Service) DeleteUserSession(user *happydns.User, sessionID string) error {
sess, err := s.GetUserSession(user, sessionID)
if err != nil {
@ -144,8 +144,9 @@ func (s *Service) DeleteUserSession(user *happydns.User, sessionID string) error
return nil
}
// CloseUserSessions closes (deletes) all sessions for the given user.
// Replaces: CloseUserSessionsUsecase.CloseAll
// CloseUserSessions deletes all sessions belonging to user. Errors from
// individual deletions are collected and returned as a combined error so that
// a single failure does not prevent the remaining sessions from being removed.
func (s *Service) CloseUserSessions(user *happydns.User) error {
sessions, err := s.ListUserSessions(user)
if err != nil {
@ -162,7 +163,8 @@ func (s *Service) CloseUserSessions(user *happydns.User) error {
return errs
}
// closeUserSessionsInternal is a helper that accepts UserInfo interface.
// closeUserSessionsInternal is like [Service.CloseUserSessions] but accepts
// the broader [happydns.UserInfo] interface.
func (s *Service) closeUserSessionsInternal(user happydns.UserInfo) error {
sessions, err := s.listUserSessionsInternal(user)
if err != nil {
@ -179,19 +181,23 @@ func (s *Service) closeUserSessionsInternal(user happydns.UserInfo) error {
return errs
}
// CloseAll implements SessionCloserUsecase interface.
// Closes all sessions for the given user.
// CloseAll deletes all sessions for user. It satisfies the
// SessionCloserUsecase interface and accepts the broader [happydns.UserInfo]
// type so callers are not required to hold a full [happydns.User] value.
func (s *Service) CloseAll(user happydns.UserInfo) error {
return s.closeUserSessionsInternal(user)
}
// ByID implements SessionCloserUsecase interface.
// Closes all sessions for a user identified by ID.
// ByID deletes all sessions for the user identified by userID. It satisfies
// the SessionCloserUsecase interface, allowing callers that only have a user
// identifier to invalidate all of that user's sessions without constructing a
// full [happydns.User] value.
func (s *Service) ByID(userID happydns.Identifier) error {
return s.CloseUserSessions(&happydns.User{Id: userID})
}
// NewSessionID generates a new random session identifier.
// NewSessionID generates a random session identifier encoded
// as a base32 string without padding characters.
func NewSessionID() string {
return strings.TrimRight(base32.StdEncoding.EncodeToString(securecookie.GenerateRandomKey(64)), "=")
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(securecookie.GenerateRandomKey(64))
}

View file

@ -25,6 +25,8 @@ import (
"git.happydns.org/happyDomain/model"
)
// SessionStorage is the persistence interface required by [Service]. Any
// storage backend that implements these methods can be injected via [NewService].
type SessionStorage interface {
// ListAllSessions retrieves the list of known Sessions.
ListAllSessions() (happydns.Iterator[happydns.Session], error)

View file

@ -0,0 +1,27 @@
// 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
// Package user implements use cases for managing happyDomain user accounts.
// It handles creation (including optional newsletter subscription), retrieval
// by ID or email, settings updates, avatar generation, and deletion. Deletion
// of locally-authenticated users is intentionally refused here; that path goes
// through the authuser package which verifies the current password first.
package user

View file

@ -23,6 +23,7 @@ package user_test
import (
"bytes"
"net/mail"
"testing"
"git.happydns.org/happyDomain/internal/storage"
@ -34,6 +35,13 @@ import (
"git.happydns.org/happyDomain/model"
)
// noopMailer is a mock mailer that discards all emails.
type noopMailer struct{}
func (n *noopMailer) SendMail(to *mail.Address, subject, content string) error {
return nil
}
// Mock implementations for testing
type mockNewsletterSubscriptor struct {
subscribed []happydns.UserInfo
@ -83,7 +91,7 @@ func createTestService(t *testing.T) (*user.Service, storage.Storage, *mockNewsl
DisableRegistration: false,
}
sessionService := sessionUC.NewService(db)
authUserService := authuserUC.NewAuthUserUsecases(cfg, nil, db, sessionService)
authUserService := authuserUC.NewAuthUserUsecases(cfg, &noopMailer{}, db, sessionService)
newsletter := &mockNewsletterSubscriptor{}
sessionCloser := &mockSessionCloser{}

View file

@ -28,16 +28,24 @@ import (
"git.happydns.org/happyDomain/services"
)
// AddRecordUsecase handles adding a single DNS record to an in-memory Zone,
// merging it into an existing compatible service or registering a new one.
type AddRecordUsecase struct {
serviceListRecordsUC *service.ListRecordsUsecase
}
// NewAddRecordUsecase constructs an AddRecordUsecase with the given service
// record-listing dependency.
func NewAddRecordUsecase(serviceListRecordsUC *service.ListRecordsUsecase) *AddRecordUsecase {
return &AddRecordUsecase{
serviceListRecordsUC: serviceListRecordsUC,
}
}
// Add inserts record into zone under origin. The record name is first
// qualified to a FQDN. If a service of the same type already exists under the
// target subdomain, the record is merged into that service and the service is
// re-analysed; otherwise a new service is appended.
func (uc *AddRecordUsecase) Add(zone *happydns.Zone, origin string, record happydns.Record) error {
record = helpers.CopyRecord(record)
@ -71,6 +79,13 @@ func (uc *AddRecordUsecase) Add(zone *happydns.Zone, origin string, record happy
return err
}
ReassociateMetadata(
map[happydns.Subdomain][]*happydns.Service{dn: {foundsamesvc}},
mergedsvc,
origin,
0,
)
// Replace in zone
zone.Services[dn] = append(zone.Services[dn][:i], append(mergedsvc[dn], zone.Services[dn][i+1:]...)...)

Some files were not shown because too many files have changed in this diff Show more