Compare commits

...

69 commits

Author SHA1 Message Date
c1c9bc8971 checkers: load external checker plugins from .so files
Some checks failed
continuous-integration/drone/push Build is failing
Scan -plugins-directory paths at startup, open each .so via plugin.Open,
look up the NewCheckerPlugin symbol from checker-sdk-go, and register the
returned definition and observation provider in the global checker
registries. A pluginLoader indirection keeps the door open for future
plugin kinds.
2026-04-08 03:02:00 +07:00
4c1306d66f checkers: add domain model types and checker core registry
Introduce the foundational types for the checker system.
Alias types from the checker-sdk-go project
2026-04-08 03:01:31 +07:00
de6eb2b3b4 fix: add missing fields to zone mock in thiszone test
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is failing
The mock zone object was missing `services` and `last_modified` fields
required by the response transformer, causing Object.keys to fail on
undefined.
2026-04-05 22:23:52 +07:00
8e2e38757f fix: guard against undefined entry in domainLink helper
Some checks failed
continuous-integration/drone/push Build is failing
Add null checks to prevent runtime errors when the domain index entry
does not exist for the given identifier.
2026-04-05 22:08:46 +07:00
6dfe3f9d42 ci: run svelte-kit sync before generate:api in Drone pipelines
openapi-ts v0.95.0 now requires a valid tsconfig chain to resolve.
Since tsconfig.json extends .svelte-kit/tsconfig.json which is generated,
we need to run svelte-kit sync first to create it.
2026-04-05 22:07:44 +07:00
397e19b745 fix(deps): update module @hey-api/openapi-ts to v0.95.0
Some checks failed
continuous-integration/drone/push Build is failing
2026-04-04 22:22:06 +07:00
cae8658f61 web: Tighten TypeScript types and fix toast API across frontend
Replace `any` with precise types (unknown, Record<string, any>, specific
interfaces) in props, function signatures, and index signatures. Introduce
ServiceWithValue to bridge SDK's HappydnsService.Service from unknown to
its runtime shape. Simplify updateDomain to take explicit id + body params.
2026-04-04 22:22:06 +07:00
f92cab2abf web: Prefer pointer as grab in ZoneList 2026-04-04 22:16:27 +07:00
30506e9731 web: Fix state_referenced_locally warning in ZoneList
Replace $state + $effect pattern with $derived for localDomains to properly react to prop changes. Also reformat genGroups signature and sort callback indentation.
2026-04-04 22:16:27 +07:00
15f014cf5a web: Replace null defaults with undefined for optional props
Aligns optional prop types and defaults with TypeScript/Svelte 5 idioms
by dropping explicit null in favor of undefined throughout components.
2026-04-04 22:16:27 +07:00
b2e85cb9f2 web: Fix variable not declared as $state 2026-04-04 22:16:27 +07:00
2679d0476e web: Fix a11y role 2026-04-04 22:16:27 +07:00
fa47e9b4b1 web: Replace ServiceCombined with HappydnsService SDK type across frontend 2026-04-04 22:16:27 +07:00
17c9e88903 web: Derive local model types from SDK-generated types to strengthen type checking
Replace hand-written interfaces with types derived from SDK-generated types
(alias, Required<>, Omit<> & extend) so the two type systems stay in sync.
This eliminates all `as any` casts and reduces `as unknown as` casts from 13
to 9 in the API wrapper layer.
2026-04-04 22:16:27 +07:00
47d7893b91 Add readonly struct tags to immutable model fields
Mark fields like IDs, timestamps, zone_history, and settings as
readonly in swagger documentation to indicate they are not user-writable.
2026-04-04 22:05:28 +07:00
e09044388b web: Move delete_in_progress UI state from Session model to component
Replace the delete_in_progress field on the Session interface with a
local Set<string> in SessionsManager, keeping model types pure.
2026-04-04 22:05:28 +07:00
3e3b23a0c4 Replace any types with proper TypeScript types in web API and model files 2026-04-04 22:05:28 +07:00
e30d1a5f50 Add is_auth_user endpoint in swagger doc and remove custom client call in frontend 2026-04-04 22:05:28 +07:00
4f3a1d7f7b Add format:"date-time" struct tags to time.Time model fields 2026-04-04 22:05:28 +07:00
7081f3d571 Add binding:required tags and narrow input types for API endpoints
Introduce dedicated input types (DomainUpdateInput, SessionInput) to limit
writable fields on update endpoints, and add binding:required tags across
model structs to improve validation and Swagger documentation accuracy.
2026-04-04 22:05:28 +07:00
899f3e0989 Fix Swagger annotations in controllers
- Fix typos in session.go (@Prodsce -> @Produce, @Ssccess -> @Success)
- Add missing @Param body annotations for CreateSession and UpdateSession
- Update ApplyZoneForm param description to reference correct type
- Add swaggertype tags to Identifier slices for correct OpenAPI output
2026-04-04 22:05:28 +07:00
c7c674f2ae fix(deps): update module github.com/lib/pq to v1.12.3
All checks were successful
continuous-integration/drone/push Build is passing
2026-04-03 16:11:03 +00:00
4efff8b0a0 fix(deps): update module github.com/lib/pq to v1.12.2
All checks were successful
continuous-integration/drone/push Build is passing
2026-04-02 11:11:08 +00:00
e9db206e78 Add context.Context to ProviderUsecase and DomainUsecase interfaces
All checks were successful
continuous-integration/drone/push Build is passing
Propagate context.Context as first parameter through all provider and
domain usecase interface methods that didn't already have it. This is
a prerequisite for the upcoming secret management layer, which needs
request-scoped context to carry session-derived encryption keys.
2026-03-30 21:54:54 +07:00
af517907d6 Fix OPENPGPKEY/SMIMEA hash to use SHA-256 truncated per RFC 7929/8162
All checks were successful
continuous-integration/drone/push Build is passing
Replace sha256.Sum224 (SHA-224 algorithm) with SHA-256 truncated to 28
bytes, matching what RFCs 7929 and 8162 actually specify and what the
frontend was already computing correctly. Also wrap GetRecords errors
with the service type name for easier diagnosis.
2026-03-30 21:54:54 +07:00
c945ba30ed Use rtypecontrol.NewRecordConfigFromStruct for modern DNS record types in DNSControlRRtoRC
Fix panic on DS records by importing dnscontrol rtype package

For record types registered in rtypecontrol.Func, bypass dnsrr.RRtoRC and
call rtypecontrol.NewRecordConfigFromStruct directly, passing the record as
the struct argument. This ensures correct handling of modern rtypes (DS, RP,
etc.) without relying on dnsrr's internal dispatch. Legacy types fall back to
the existing dnsrr.RRtoRC path.

Import _ "github.com/StackExchange/dnscontrol/v4/pkg/rtype" to trigger
init() registration of DS and RP as modern types. Without this import,
dnsrr.RRtoRC would panic with "DS should be handled as modern type".
2026-03-30 21:54:29 +07:00
e7b1f4780e provider_settings: Only update content + type and comment
All checks were successful
continuous-integration/drone/push Build is passing
The ProviderMessage does not contain necesseraly all
metadata. Moreover some metadata shouldn't be updated (Owner, Id,
...). So keep the modifications to the minimum.
2026-03-30 19:37:48 +07:00
987c1bb72e Add IONOS libdns provider implementations
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-30 11:58:03 +07:00
25f37af35d Add libdns provider adapter for supporting libdns-based DNS providers
Introduce a new adapter layer that allows happyDomain to use providers
from the libdns ecosystem alongside the existing dnscontrol providers.
The adapter implements ProviderActuator by converting between miekg/dns
and libdns record formats, reusing the existing DNSControl diff engine
for computing corrections, and generating executable correction functions
that call libdns Append/Delete/Set methods.
2026-03-30 11:58:02 +07:00
e1eb4dec90 Fix zone parenting after deploy 2026-03-30 11:58:02 +07:00
d298992b63 Re-fetch zone after deploy to capture actual SOA serial
For providers that manage SOA serials (like AXFRDDNS), re-fetch the
zone after applying corrections to capture the real published state.
The snapshot and WIP zone's Origin service are both updated with the
actual serial.

This is gated on a new "manages-soa-serial" capability, preserving
the existing behavior for providers that abstract SOA handling.

Closes: https://github.com/happyDomain/happydomain/issues/35
2026-03-30 11:58:02 +07:00
aba39001d8 New helper to check provider capability
Some checks are pending
continuous-integration/drone/push Build is running
2026-03-30 11:58:02 +07:00
d600bbcbd9 web: Refactor domain Table component to improve single responsibility
Extract DomainTableRow and ProviderLink components from the monolithic
Table.svelte, which was mixing table structure, provider name resolution,
row navigation, and action button logic into a single component.
2026-03-30 11:58:02 +07:00
e103d2262a Add DNS propagation time tracking per service
After publishing zone corrections, compute and store a PropagatedAt
timestamp on each affected service indicating when old cached records
will have expired. For updated/deleted services, this is publish_time +
old service TTL. For new services, it uses the SOA minimum TTL
(negative cache duration), falling back to the zone's DefaultTTL.

The propagation detection reuses the same service matching technique as
ReassociateMetadata (subdomain + type + ServiceRDataHash). Both the
published snapshot and the WIP zone are stamped.
2026-03-30 11:58:02 +07:00
ca9dc450c3 chore(deps): lock file maintenance
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-30 00:12:53 +00:00
021d8bd8f9 Fix RegisterDNSControlProviderAdapter to preserve pre-set capabilities
All checks were successful
continuous-integration/drone/push Build is passing
Change infos.Capabilities to append instead of overwrite
2026-03-28 21:45:42 +07:00
8adc08b4c0 Remove redundant work in ACME and server analyzers 2026-03-28 21:42:17 +07:00
3f2c923754 Fix GenComment showing only NS count instead of SOA details 2026-03-28 21:42:17 +07:00
1523b549d2 fix: propagate UseRR errors consistently across all service analyzers
Several service analyzers were silently discarding errors returned by
a.UseRR(), unlike txt.go and others that properly checked them. This
could mask issues like double-claimed records during zone analysis.
2026-03-28 21:42:17 +07:00
e95ecd6671 Fix port/protocol not extracted from existing TLSA and SRV records 2026-03-28 21:42:17 +07:00
ea0324c3a7 Fix XMPP editor not displaying existing records 2026-03-28 21:42:17 +07:00
d06b73c3dd Fix RFC6186 editor not displaying records and failing on new service 2026-03-28 21:42:17 +07:00
8c9a38166b web: pause toast timers when page is not visible
Use the Page Visibility API to pause toast countdowns and progress bar
animations when the tab is hidden, and resume them when the user returns.
New toasts created while the page is hidden also start in a paused state.
2026-03-28 21:42:17 +07:00
e89a483725 web: add missing timeouts to toast notifications
Add timeout values to the 3 remaining toast calls that lacked them:
- Session error toast on redirect to login (10s)
- Account deleted success toast (5s)
- Database tidy error toast in web-admin (10s)
2026-03-28 21:42:17 +07:00
074e5e864e web: Add progress bar and improve pause/resume behavior
Add a visual progress bar that shrinks over the toast timeout duration,
pause/resume the timer on hover.
2026-03-28 21:42:17 +07:00
e20f1dce9d fix(deps): update module github.com/gin-contrib/sessions to v1.1.0
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-28 04:11:06 +00:00
40b890a8e3 web: replace @paralleldrive/cuid2 with crypto.randomUUID()
All checks were successful
continuous-integration/drone/push Build is passing
Use the standard Web Crypto API instead of an external dependency for
generating unique IDs in Toast and ProviderForm.
2026-03-26 08:02:15 +07:00
08c4749607 fix: propagate iterator errors instead of silently returning partial results
Add Err() method to storage.Iterator interface and implement it on all
backends (LevelDB, PostgreSQL, in-memory). Update kvtpl.KVIterator.Err()
to fall through to the underlying iterator's error. Check Err() after
every iteration loop across kvtpl and usecase layers to surface I/O
failures and rate-limit errors instead of returning truncated data.
2026-03-26 08:02:15 +07:00
8e82d22c77 oracle-nosql: improve search with prepared statements and rate-limit handling
Use a prepared statement with bind variables for Search() to avoid SQL
injection via regex-special characters. Enable client-side rate limiting,
add retry-with-backoff on rate-limit errors in the iterator, fix batch
iteration logic, and expose Err()/Release() on Iterator.
2026-03-26 08:02:15 +07:00
277e45e807 fix(deps): update module github.com/yuin/goldmark to v1.8.2
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-25 06:10:45 +00:00
08a365b8e8 fix(deps): update module github.com/yuin/goldmark to v1.8.1
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-24 10:10:32 +00:00
e571a629b7 services: move service registry to internal/service package
All checks were successful
continuous-integration/drone/push Build is passing
Analogous to internal/provider, extract the service registry (Svc,
RegisterService, FindService, ListServices, OrderedServices, FindSubService,
RegisterSubServices) and the zone analyzer (ServiceAnalyzer, Analyzer,
AnalyzeZone) from services/ into a new internal/service package.
2026-03-24 14:59:24 +07:00
5761850c7f providers: move provider registry to internal/provider package
Move RegisterProvider, GetProviders and FindProvider out of providers/
into a dedicated internal/provider registry.
2026-03-24 14:59:24 +07:00
85b422ed39 fix(deps): update module github.com/yuin/goldmark to v1.8.0
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-24 03:10:23 +00:00
b60dd41455 chore(deps): lock file maintenance
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-23 00:11:59 +00:00
7d89e120a9 web: Add propagation loading screen and wait.preparation/propagation locale keys
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-22 07:42:13 +07:00
a47d0d4196 web: Add service.edit locale key and use it in ServiceDetailsOffcanvas 2026-03-22 07:42:13 +07:00
94f1884ba5 web: Extract domainLink as a shared store utility
Move the domainLink helper from local component functions into the
domains store so it can be reused across layout, sidebars, and page
components.

This fixes links to non-unique domain names.
2026-03-22 07:42:13 +07:00
6068e81ff6 fix(deps): update module github.com/fatih/color to v1.19.0
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-20 09:10:26 +00:00
e183aa6ea9 fix(deps): update module github.com/yuin/goldmark to v1.7.17
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-19 07:10:21 +00:00
c020aeece7 chore(deps): lock file maintenance
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-19 04:10:55 +00:00
38caea104f chore(deps): lock file maintenance
Some checks are pending
continuous-integration/drone/push Build is running
2026-03-19 10:43:27 +07:00
ae88b5d892 chore(deps): update dependency @sveltejs/vite-plugin-svelte to v7
Some checks are pending
continuous-integration/drone/push Build is pending
2026-03-19 10:41:50 +07:00
17d6ebd607 chore(deps): update dependency vite to v8
Some checks are pending
continuous-integration/drone/push Build is running
2026-03-19 03:27:25 +00:00
1b4cffec2d chore(deps): update eslint monorepo to v10
Some checks failed
continuous-integration/drone/push Build is failing
2026-03-19 03:16:57 +00:00
11aea3d303 fix(deps): update module github.com/lib/pq to v1.12.0
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-18 15:12:06 +00:00
bff4e02273 Add input validation for service fields
All checks were successful
continuous-integration/drone/push Build is passing
Introduce ValidateStructValues helpers in the forms package to enforce
type correctness and choice constraints on dynamically-typed values.
2026-03-17 20:14:57 +07:00
5e0eaa5d11 Include generated services_specs into frontend code
This permit to prerender generator pages so they can be referenced
2026-03-17 20:14:57 +07:00
4f9a308a2d Fix service worker registration 2026-03-17 11:20:48 +07:00
339 changed files with 6810 additions and 3252 deletions

View file

@ -24,9 +24,9 @@ steps:
- tar --transform="s@.@./happydomain-${DRONE_COMMIT}@" --exclude-vcs --exclude=./web/node_modules/.cache -czf /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 run svelte-kit sync && 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 run svelte-kit sync && 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: backend-commit
@ -218,11 +218,11 @@ steps:
commands:
- cd web
- npm install --network-timeout=100000
- npm run generate:api
- npx svelte-kit sync && npm run generate:api
- npm test
- npm run build
- cd ../web-admin
- npm run generate:api
- npx svelte-kit sync && npm run generate:api
- npm test
- npm run build

View file

@ -24,6 +24,7 @@ package main
//go:generate go run tools/gen_icon.go providers providers
//go:generate go run tools/gen_icon.go services svcs
//go:generate go run tools/gen_rr_typescript.go web/src/lib/dns_rr.ts
//go:generate go run tools/gen_service_specs.go -o web/src/lib/services_specs.ts
//go:generate go run tools/gen_dns_type_mapping.go -o internal/usecase/service_specs_dns_types.go
//go:generate swag init --exclude internal/api-admin/ --generalInfo internal/api/route/route.go
//go:generate swag init --output docs-admin --exclude internal/api/ --generalInfo internal/api-admin/route/route.go

13
go.mod
View file

@ -9,14 +9,16 @@ require (
github.com/altcha-org/altcha-lib-go v1.0.0
github.com/coreos/go-oidc/v3 v3.17.0
github.com/earthboundkid/versioninfo/v2 v2.24.1
github.com/fatih/color v1.18.0
github.com/gin-contrib/sessions v1.0.4
github.com/fatih/color v1.19.0
github.com/gin-contrib/sessions v1.1.0
github.com/gin-gonic/gin v1.12.0
github.com/go-mail/mail v2.3.1+incompatible
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/gorilla/securecookie v1.1.2
github.com/gorilla/sessions v1.4.0
github.com/lib/pq v1.11.2
github.com/lib/pq v1.12.3
github.com/libdns/ionos v1.2.0
github.com/libdns/libdns v1.1.1
github.com/miekg/dns v1.1.72
github.com/mileusna/useragent v1.3.5
github.com/oracle/nosql-go-sdk v1.4.7
@ -26,7 +28,7 @@ require (
github.com/swaggo/gin-swagger v1.6.1
github.com/swaggo/swag v1.16.6
github.com/syndtr/goleveldb v1.0.0
github.com/yuin/goldmark v1.7.16
github.com/yuin/goldmark v1.8.2
golang.org/x/crypto v0.49.0
golang.org/x/oauth2 v0.36.0
)
@ -74,7 +76,6 @@ require (
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 // indirect
github.com/aws/smithy-go v1.24.2 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/babolivier/go-doh-client v0.0.0-20201028162107-a76cff4cb8b6 // indirect
github.com/benbjohnson/clock v1.3.5 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/billputer/go-namecheap v0.0.0-20210108011502-994a912fb7f9 // indirect
@ -90,7 +91,6 @@ require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/deepmap/oapi-codegen v1.16.3 // indirect
github.com/digitalocean/godo v1.176.0 // indirect
github.com/dnsimple/dnsimple-go v1.7.0 // indirect
github.com/dnsimple/dnsimple-go/v8 v8.1.0 // indirect
github.com/exoscale/egoscale v0.102.4 // indirect
github.com/failsafe-go/failsafe-go v0.9.6 // indirect
@ -216,7 +216,6 @@ require (
go.opentelemetry.io/otel v1.40.0 // indirect
go.opentelemetry.io/otel/metric v1.40.0 // indirect
go.opentelemetry.io/otel/trace v1.40.0 // indirect
go.uber.org/mock v0.6.0 // indirect
go.uber.org/ratelimit v0.3.1 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect

86
go.sum
View file

@ -5,8 +5,6 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
codeberg.org/miekg/dns v0.6.62 h1:3Uua303EC8Og75QqT+pGRrcvKNTOouehHOQS36KbSqc=
codeberg.org/miekg/dns v0.6.62/go.mod h1:fIxAzBMDPnXWSw0fp8+pfZMRiAqYY4+HHYLzUo/S6Dg=
codeberg.org/miekg/dns v0.6.67 h1:vsVNsqAOE9uYscJHIHNtoCxiEySQn/B9BEvAUYI5Zmc=
codeberg.org/miekg/dns v0.6.67/go.mod h1:58Y3ZTg6Z5ZEm/ZAAwHehbZfrD4u5mE4RByHoPEMyKk=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
@ -75,74 +73,40 @@ github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/aws/aws-sdk-go-v2 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls=
github.com/aws/aws-sdk-go-v2 v1.41.2/go.mod h1:IvvlAZQXvTXznUPfRVfryiG1fbzE2NGK6m9u39YQ+S4=
github.com/aws/aws-sdk-go-v2 v1.41.3 h1:4kQ/fa22KjDt13QCy1+bYADvdgcxpfH18f0zP542kZA=
github.com/aws/aws-sdk-go-v2 v1.41.3/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/config v1.32.10 h1:9DMthfO6XWZYLfzZglAgW5Fyou2nRI5CuV44sTedKBI=
github.com/aws/aws-sdk-go-v2/config v1.32.10/go.mod h1:2rUIOnA2JaiqYmSKYmRJlcMWy6qTj1vuRFscppSBMcw=
github.com/aws/aws-sdk-go-v2/config v1.32.11 h1:ftxI5sgz8jZkckuUHXfC/wMUc8u3fG1vQS0plr2F2Zs=
github.com/aws/aws-sdk-go-v2/config v1.32.11/go.mod h1:twF11+6ps9aNRKEDimksp923o44w/Thk9+8YIlzWMmo=
github.com/aws/aws-sdk-go-v2/credentials v1.19.10 h1:EEhmEUFCE1Yhl7vDhNOI5OCL/iKMdkkYFTRpZXNw7m8=
github.com/aws/aws-sdk-go-v2/credentials v1.19.10/go.mod h1:RnnlFCAlxQCkN2Q379B67USkBMu1PipEEiibzYN5UTE=
github.com/aws/aws-sdk-go-v2/credentials v1.19.11 h1:NdV8cwCcAXrCWyxArt58BrvZJ9pZ9Fhf9w6Uh5W3Uyc=
github.com/aws/aws-sdk-go-v2/credentials v1.19.11/go.mod h1:30yY2zqkMPdrvxBqzI9xQCM+WrlrZKSOpSJEsylVU+8=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 h1:Ii4s+Sq3yDfaMLpjrJsqD6SmG/Wq/P5L/hw2qa78UAY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18/go.mod h1:6x81qnY++ovptLE6nWQeWrpXxbnlIex+4H4eYYGcqfc=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 h1:INUvJxmhdEbVulJYHI061k4TVuS3jzzthNvjqvVvTKM=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19/go.mod h1:FpZN2QISLdEBWkayloda+sZjVJL+e9Gl0k1SyTgcswU=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 h1:F43zk1vemYIqPAwhjTjYIz0irU2EY7sOb/F5eJ3HuyM=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18/go.mod h1:w1jdlZXrGKaJcNoL+Nnrj+k5wlpGXqnNrKoP22HvAug=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 h1:/sECfyq2JTifMI2JPyZ4bdRN77zJmr6SrS1eL3augIA=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19/go.mod h1:dMf8A5oAqr9/oxOfLkC/c2LU/uMcALP0Rgn2BD5LWn0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 h1:xCeWVjj0ki0l3nruoyP2slHsGArMxeiiaoPN5QZH6YQ=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18/go.mod h1:r/eLGuGCBw6l36ZRWiw6PaZwPXb6YOj+i/7MizNl5/k=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 h1:AWeJMk33GTBf6J20XJe6qZoRSJo0WfUhsMdUKhoODXE=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19/go.mod h1:+GWrYoaAsV7/4pNHpwh1kiNLXkKaSoppxQq9lbH8Ejw=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 h1:clHU5fm//kWS1C2HgtgWxfQbFbx4b6rx+5jzhgX9HrI=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5 h1:CeY9LUdur+Dxoeldqoun6y4WtJ3RQtzk0JMP2gfUay0=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5/go.mod h1:AZLZf2fMaahW5s/wMRciu1sYbdsikT/UHwbUjOdEVTc=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 h1:XAq62tBTJP/85lFD5oqOOe7YYgWxY9LvWq8plyDvDVg=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18 h1:LTRCYFlnnKFlKsyIQxKhJuDuA3ZkrDQMRYm6rXiHlLY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18/go.mod h1:XhwkgGG6bHSd00nO/mexWTcTjgd6PjuvWQMqSn2UaEk=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 h1:X1Tow7suZk9UCJHE1Iw9GMZJJl0dAnKXXP1NaSDHwmw=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19/go.mod h1:/rARO8psX+4sfjUQXp5LLifjUt8DuATZ31WptNJTyQA=
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.2 h1:zoD/SoiVQi8l8tuQn//VexrXS2yorg/+717JNA4Ble8=
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.2/go.mod h1:Ll1DCasPTBFtHK5t/U5WIwGIyRuY3xY+x8/LmqIlqpM=
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.3 h1:JRPXnIr0WwFsSHBmuCvT/uh0Vgys+crvwkOghbJEqi8=
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.3/go.mod h1:DHddp7OO4bY467WVCqWBzk5+aEWn7vqYkap7UigJzGk=
github.com/aws/aws-sdk-go-v2/service/route53domains v1.34.16 h1:k+TqYbG/WtL43wSCALuuPjLPEt//Ck/ZDKpCWrzhjUU=
github.com/aws/aws-sdk-go-v2/service/route53domains v1.34.16/go.mod h1:yEr1gPPNbetOFxQV0J9ZLL5cR4U4ujEBgwk6p6oKYc8=
github.com/aws/aws-sdk-go-v2/service/route53domains v1.34.17 h1:Fw2SIR63jhfLpFZr6955zU3g9V8ouHC/pRpmmiHmIFM=
github.com/aws/aws-sdk-go-v2/service/route53domains v1.34.17/go.mod h1:x9PRRtbCQ/gv1ziQPXFB7nQwQgVLQ+FSvPIkVAhRcYY=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 h1:MzORe+J94I+hYu2a6XmV5yC9huoTv8NRcCrUNedDypQ=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.6/go.mod h1:hXzcHLARD7GeWnifd8j9RWqtfIgxj4/cAtIVIK7hg8g=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 h1:Y2cAXlClHsXkkOvWZFXATr34b0hxxloeQu/pAZz2row=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7/go.mod h1:idzZ7gmDeqeNrSPkdbtMp9qWMgcBwykA7P7Rzh5DXVU=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 h1:7oGD8KPfBOJGXiCoRKrrrQkbvCp8N++u36hrLMPey6o=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.11/go.mod h1:0DO9B5EUJQlIDif+XJRWCljZRKsAFKh3gpFz7UnDtOo=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 h1:iSsvB9EtQ09YrsmIc44Heqlx5ByGErqhPK1ZQLppias=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12/go.mod h1:fEWYKTRGoZNl8tZ77i61/ccwOMJdGxwOhWCkp6TXAr0=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 h1:edCcNp9eGIUDUCrzoCu1jWAXLGFIizeqkdkKgRlJwWc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15/go.mod h1:lyRQKED9xWfgkYC/wmmYfv7iVIM68Z5OQ88ZdcV1QbU=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 h1:EnUdUqRP1CNzt2DkV67tJx6XDN4xlfBFm+bzeNOQVb0=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16/go.mod h1:Jic/xv0Rq/pFNCh3WwpH4BEqdbSAl+IyHro8LbibHD8=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 h1:NITQpgo9A5NrDZ57uOWj+abvXSb83BbyggcUBVksN7c=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.7/go.mod h1:sks5UWBhEuWYDPdwlnRFn1w7xWdH29Jcpe+/PJQefEs=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 h1:XQTQTF75vnug2TXS8m7CVJfC2nniYPZnO1D4Np761Oo=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8/go.mod h1:Xgx+PR1NUOjNmQY+tRMnouRp83JRM8pRMw/vCaVhPkI=
github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0=
github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/babolivier/go-doh-client v0.0.0-20201028162107-a76cff4cb8b6 h1:4NNbNM2Iq/k57qEu7WfL67UrbPq1uFWxW4qODCohi+0=
github.com/babolivier/go-doh-client v0.0.0-20201028162107-a76cff4cb8b6/go.mod h1:J29hk+f9lJrblVIfiJOtTFk+OblBawmib4uz/VdKzlg=
github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o=
github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@ -181,12 +145,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/deepmap/oapi-codegen v1.16.3 h1:GT9G86SbQtT1r8ZB+4Cybi9VGdu1P5ieNvNdEoCSbrA=
github.com/deepmap/oapi-codegen v1.16.3/go.mod h1:JD6ErqeX0nYnhdciLc61Konj3NBASREMlkHOgHn8WAM=
github.com/digitalocean/godo v1.175.0 h1:tpfwJFkBzpePxvvFazOn69TXctdxuFlOs7DMVXsI7oU=
github.com/digitalocean/godo v1.175.0/go.mod h1:xQsWpVCCbkDrWisHA72hPzPlnC+4W5w/McZY5ij9uvU=
github.com/digitalocean/godo v1.176.0 h1:P379vPO5TUre+bUHPEsdSAbl5vIrRRhP91tMIEPoWYU=
github.com/digitalocean/godo v1.176.0/go.mod h1:xQsWpVCCbkDrWisHA72hPzPlnC+4W5w/McZY5ij9uvU=
github.com/dnsimple/dnsimple-go v1.7.0 h1:JKu9xJtZ3SqOC+BuYgAWeab7+EEx0sz422vu8j611ZY=
github.com/dnsimple/dnsimple-go v1.7.0/go.mod h1:EKpuihlWizqYafSnQHGCd/gyvy3HkEQJ7ODB4KdV8T8=
github.com/dnsimple/dnsimple-go/v8 v8.1.0 h1:U4ENaNCe5aUFHLiF7lj2NNpLPzFY3YIriu/UzrdfUbg=
github.com/dnsimple/dnsimple-go/v8 v8.1.0/go.mod h1:61MdYHRL+p2TBBUVEkxo1n4iRF6s3R9fZcvQvyt5du8=
github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg=
@ -200,8 +160,8 @@ github.com/failsafe-go/failsafe-go v0.9.6 h1:vPSH2cry0Ee5cnR9wc9qshCDO6jdrMA9elB
github.com/failsafe-go/failsafe-go v0.9.6/go.mod h1:IeRpglkcwzKagjDMh90ZhN2l4Ovt3+jemQBUbThag54=
github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
@ -214,12 +174,10 @@ github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kbcqy8U=
github.com/gin-contrib/sessions v1.0.4/go.mod h1:ccmkrb2z6iU2osiAHZG3x3J4suJK+OU27oqzlWOqQgs=
github.com/gin-contrib/sessions v1.1.0 h1:00mhHfNEGF5sP2fwxa98aRqj1FOJdL6IkR86n2hOiBo=
github.com/gin-contrib/sessions v1.1.0/go.mod h1:TyYZDIs6qCQg2SOoYPgMT9pAkmZceVNEJMcv5qbIy60=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-gandi/go-gandi v0.7.0 h1:gsP33dUspsN1M+ZW9HEgHchK9HiaSkYnltO73RHhSZA=
@ -351,8 +309,6 @@ github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
github.com/happyDomain/dnscontrol/v4 v4.35.101 h1:9GL4OZ05AXOBUNjRdU72UUJGFAQz6OTCRVQw3WTQuo8=
github.com/happyDomain/dnscontrol/v4 v4.35.101/go.mod h1:R6j+Fv+etKriXI3runhnv42nPZPLcn81NNRt9gl1hTs=
github.com/happyDomain/dnscontrol/v4 v4.36.100 h1:wrNaUV3Ihcqd9t9+AEIyBiyF1QNAeuFbCj+j8w0a/sM=
github.com/happyDomain/dnscontrol/v4 v4.36.100/go.mod h1:7fgVrun0ecnT8fJhcFHQQXBg6yVIfEWRRQOj27hxm+s=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
@ -430,8 +386,16 @@ github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs=
github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/lib/pq v1.12.0 h1:mC1zeiNamwKBecjHarAr26c/+d8V5w/u4J0I/yASbJo=
github.com/lib/pq v1.12.0/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/lib/pq v1.12.2 h1:ajJNv84limnK3aPbDIhLtcjrUbqAw/5XNdkuI6KNe/Q=
github.com/lib/pq v1.12.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/libdns/ionos v1.2.0 h1:FQ2xQTBfsjc7aMArRBBCs9l48Squt76GHXbxDsqOKgw=
github.com/libdns/ionos v1.2.0/go.mod h1:g/JYno/+VXdujTGPBDMDeCfeLF0PJyJynsCrFu+2EFQ=
github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U=
github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
github.com/luadns/luadns-go v0.3.0 h1:mN2yhFv/LnGvPw/HmvYUhXe+lc95oXUqjlYVeJeOJng=
github.com/luadns/luadns-go v0.3.0/go.mod h1:DmPXbrGMpynq1YNDpvgww3NP5Zf4wXM5raAbGrp5L+8=
github.com/mailgun/raymond/v2 v2.0.48 h1:5dmlB680ZkFG2RN/0lvTAghrSxIESeu9/2aeDqACtjw=
@ -488,8 +452,6 @@ github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:Ff
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU=
github.com/oracle/nosql-go-sdk v1.4.7 h1:dqVBSMulObDj0JHm1mAncTHrQg8wIiQJNC0JRNKPACg=
github.com/oracle/nosql-go-sdk v1.4.7/go.mod h1:xgJE9wxADDbk7vR4FGA4NOt4RNAaIsQOj4sCATmCVXM=
github.com/oracle/oci-go-sdk/v65 v65.108.3 h1:n2G4QwGoRNhtLE8r24/+Ny+WpEMdc9ggGpnPvVYM2Yk=
github.com/oracle/oci-go-sdk/v65 v65.108.3/go.mod h1:8ZzvzuEG/cFLFZhxg/Mg1w19KqyXBKO3c17QIc5PkGs=
github.com/oracle/oci-go-sdk/v65 v65.109.0 h1:EsbFVvcV+uid9SoQnFQbTKS6FgqsM9NtoKmUIovKsbk=
github.com/oracle/oci-go-sdk/v65 v65.109.0/go.mod h1:8ZzvzuEG/cFLFZhxg/Mg1w19KqyXBKO3c17QIc5PkGs=
github.com/ovh/go-ovh v1.9.0 h1:6K8VoL3BYjVV3In9tPJUdT7qMx9h0GExN9EXx1r2kKE=
@ -516,8 +478,6 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/prometheus/procfs v0.20.0 h1:AA7aCvjxwAquZAlonN7888f2u4IN8WVeFgBi4k82M4Q=
github.com/prometheus/procfs v0.20.0/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/qdm12/reprint v0.0.0-20200326205758-722754a53494 h1:wSmWgpuccqS2IOfmYrbRiUgv+g37W5suLLLxwwniTSc=
@ -638,8 +598,8 @@ github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3Ifn
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.mongodb.org/mongo-driver v1.13.1/go.mod h1:wcDf1JBCXy2mOW0bWHwO/IOYqdca1MPCwDtFu/Z9+eo=
go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU=
go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ=
@ -684,8 +644,6 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@ -732,13 +690,9 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -751,8 +705,6 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -782,8 +734,6 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
@ -807,8 +757,6 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
@ -841,8 +789,6 @@ gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E
gonum.org/v1/netlib v0.0.0-20181029234149-ec6d1f5cefe6/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw=
gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw=
gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc=
google.golang.org/api v0.268.0 h1:hgA3aS4lt9rpF5RCCkX0Q2l7DvHgvlb53y4T4u6iKkA=
google.golang.org/api v0.268.0/go.mod h1:HXMyMH496wz+dAJwD/GkAPLd3ZL33Kh0zEG32eNvy9w=
google.golang.org/api v0.269.0 h1:qDrTOxKUQ/P0MveH6a7vZ+DNHxJQjtGm/uvdbdGXCQg=
google.golang.org/api v0.269.0/go.mod h1:N8Wpcu23Tlccl0zSHEkcAZQKDLdquxK+l9r2LkwAauE=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=

View file

@ -27,7 +27,10 @@ import (
dnscontrol "github.com/StackExchange/dnscontrol/v4/models"
"github.com/StackExchange/dnscontrol/v4/pkg/diff2"
"github.com/StackExchange/dnscontrol/v4/pkg/domaintags"
"github.com/StackExchange/dnscontrol/v4/pkg/dnsrr"
_ "github.com/StackExchange/dnscontrol/v4/pkg/rtype"
"github.com/StackExchange/dnscontrol/v4/pkg/rtypecontrol"
"github.com/miekg/dns"
"git.happydns.org/happyDomain/model"
@ -118,6 +121,7 @@ func DNSControlDiffByRecord(oldrrs []happydns.Record, newrrs []happydns.Record,
// before converting to DNSControl format.
// The origin parameter specifies the zone name (with or without trailing dot).
func DNSControlRRtoRC(rrs []happydns.Record, origin string) (dnscontrol.Records, error) {
originNoTrailingDot := strings.TrimSuffix(origin, ".")
records := make([]*dnscontrol.RecordConfig, len(rrs))
for i, rr := range rrs {
@ -126,10 +130,25 @@ func DNSControlRRtoRC(rrs []happydns.Record, origin string) (dnscontrol.Records,
rr = record.ToRR()
}
rc, err := dnsrr.RRtoRC(rr.(dns.RR), strings.TrimSuffix(origin, "."))
if err != nil {
return nil, err
typeName := dns.TypeToString[rr.Header().Rrtype]
var rc dnscontrol.RecordConfig
var err error
if _, ok := rtypecontrol.Func[typeName]; ok {
dcn := domaintags.MakeDomainNameVarieties(originNoTrailingDot)
rcPtr, e := rtypecontrol.NewRecordConfigFromStruct(rr.Header().Name, rr.Header().Ttl, typeName, rr, dcn)
if e != nil {
return nil, e
}
rc = *rcPtr
} else {
rc, err = dnsrr.RRtoRC(rr.(dns.RR), originNoTrailingDot)
if err != nil {
return nil, err
}
}
records[i] = &rc
}

View file

@ -39,7 +39,7 @@ import (
// and sets the help link to the DNSControl documentation for that provider.
func RegisterDNSControlProviderAdapter(creator happydns.ProviderCreatorFunc, infos happydns.ProviderInfos, registerFunc happydns.RegisterProviderFunc) {
prvInstance := creator().(DNSControlConfigAdapter)
infos.Capabilities = GetDNSControlProviderCapabilities(prvInstance)
infos.Capabilities = append(infos.Capabilities, GetDNSControlProviderCapabilities(prvInstance)...)
infos.HelpLink = "https://docs.dnscontrol.org/service-providers/providers/" + strings.ToLower(prvInstance.DNSControlName())
registerFunc(creator, infos)
@ -194,7 +194,13 @@ func (p *DNSControlAdapterNSProvider) GetZoneRecords(domain string) (ret []happy
}
for _, rec := range records {
ret = append(ret, rec.ToRR())
// rec.ToRR() for modern types (DS, RP, …) returns the rtype wrapper
// (e.g. *rtype.DS) rather than the canonical *dns.DS. When these are
// later passed back through dnsrr.RRtoRC → DS.FromStruct, the type
// assertion fields.(*dns.DS) fails, causing a nil-dereference panic.
// dns.Copy invokes the promoted copy() method from the embedded *dns.DS,
// which returns a canonical *dns.DS and eliminates the mismatch.
ret = append(ret, dns.Copy(rec.ToRR()))
}
return

View file

@ -0,0 +1,317 @@
// 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 adapter
import (
"context"
"fmt"
"strings"
"github.com/libdns/libdns"
"github.com/miekg/dns"
"git.happydns.org/happyDomain/model"
)
// LibdnsConfigAdapter is an interface that provider configurations must implement
// to work with libdns. It allows retrieving the underlying libdns provider instance.
type LibdnsConfigAdapter interface {
// LibdnsProvider returns the underlying libdns provider instance.
// The returned value must implement at least libdns.RecordGetter.
LibdnsProvider() any
}
// RegisterLibdnsProviderAdapter registers a DNS provider that uses libdns as its backend.
// It automatically populates the provider's capabilities by checking which libdns
// interfaces the provider implements.
func RegisterLibdnsProviderAdapter(creator happydns.ProviderCreatorFunc, infos happydns.ProviderInfos, registerFunc happydns.RegisterProviderFunc) {
prvInstance := creator().(LibdnsConfigAdapter)
infos.Capabilities = append(infos.Capabilities, GetLibdnsProviderCapabilities(prvInstance)...)
registerFunc(creator, infos)
}
// GetLibdnsProviderCapabilities checks which libdns interfaces the provider implements
// and returns the corresponding capability strings. Since libdns providers are type-agnostic,
// common record types are declared for all providers.
func GetLibdnsProviderCapabilities(prvd LibdnsConfigAdapter) (caps []string) {
p := prvd.LibdnsProvider()
if _, ok := p.(libdns.ZoneLister); ok {
caps = append(caps, "ListDomains")
}
// libdns providers are type-agnostic, so declare support for common RR types.
for _, v := range []uint16{
dns.TypeA, dns.TypeAAAA, dns.TypeCNAME, dns.TypeMX,
dns.TypeNS, dns.TypeTXT, dns.TypeSRV, dns.TypeCAA,
dns.TypePTR,
} {
caps = append(caps, fmt.Sprintf("rr-%d-%s", v, dns.TypeToString[v]))
}
return
}
// NewLibdnsProviderAdapter creates a new provider actuator instance from a libdns configuration.
// It discovers the provider's capabilities by checking which libdns interfaces it implements.
// The provider must implement at least libdns.RecordGetter.
func NewLibdnsProviderAdapter(configAdapter LibdnsConfigAdapter) (happydns.ProviderActuator, error) {
p := configAdapter.LibdnsProvider()
adapter := &LibdnsAdapterNSProvider{
provider: p,
}
if g, ok := p.(libdns.RecordGetter); ok {
adapter.getter = g
} else {
return nil, fmt.Errorf("libdns provider must implement RecordGetter")
}
if s, ok := p.(libdns.RecordSetter); ok {
adapter.setter = s
}
if a, ok := p.(libdns.RecordAppender); ok {
adapter.appender = a
}
if d, ok := p.(libdns.RecordDeleter); ok {
adapter.deleter = d
}
if z, ok := p.(libdns.ZoneLister); ok {
adapter.zoneLister = z
}
return adapter, nil
}
// LibdnsAdapterNSProvider wraps a libdns provider to implement the happyDomain ProviderActuator interface.
type LibdnsAdapterNSProvider struct {
provider any
getter libdns.RecordGetter
setter libdns.RecordSetter
appender libdns.RecordAppender
deleter libdns.RecordDeleter
zoneLister libdns.ZoneLister
}
// normalizeZone ensures the zone name has a trailing dot (FQDN format expected by libdns).
func normalizeZone(domain string) string {
zone := strings.TrimSuffix(domain, ".")
return zone + "."
}
// CanListZones checks if the provider supports listing zones.
func (p *LibdnsAdapterNSProvider) CanListZones() bool {
return p.zoneLister != nil
}
// CanCreateDomain returns false since libdns has no zone creation interface.
func (p *LibdnsAdapterNSProvider) CanCreateDomain() bool {
return false
}
// CreateDomain is not supported by libdns providers.
func (p *LibdnsAdapterNSProvider) CreateDomain(fqdn string) error {
return fmt.Errorf("libdns provider does not support domain creation")
}
// ListZones retrieves the list of all zones managed by this provider.
func (p *LibdnsAdapterNSProvider) ListZones() ([]string, error) {
if p.zoneLister == nil {
return nil, fmt.Errorf("libdns provider does not support zone listing")
}
zones, err := p.zoneLister.ListZones(context.TODO())
if err != nil {
return nil, err
}
result := make([]string, len(zones))
for i, z := range zones {
result[i] = z.Name
}
return result, nil
}
// GetZoneRecords retrieves all DNS records for the specified domain from the provider.
func (p *LibdnsAdapterNSProvider) GetZoneRecords(domain string) ([]happydns.Record, error) {
zone := normalizeZone(domain)
recs, err := p.getter.GetRecords(context.TODO(), zone)
if err != nil {
return nil, err
}
return libdnsRecordsToHappyDNS(recs, zone)
}
// GetZoneCorrections compares desired records against the current zone state and returns
// the changes needed to synchronize them. It uses the DNSControl diff engine to compute
// the diff, then creates correction functions that call the libdns provider's API.
func (p *LibdnsAdapterNSProvider) GetZoneCorrections(domain string, wantedRecords []happydns.Record) ([]*happydns.Correction, int, error) {
zone := normalizeZone(domain)
// Step 1: Fetch current records from the provider.
currentLibdnsRecs, err := p.getter.GetRecords(context.TODO(), zone)
if err != nil {
return nil, 0, fmt.Errorf("unable to get current zone records: %w", err)
}
currentRecords, err := libdnsRecordsToHappyDNS(currentLibdnsRecs, zone)
if err != nil {
return nil, 0, fmt.Errorf("unable to convert current zone records: %w", err)
}
// Step 2: Compute diff using existing DNSControl diff engine.
diffs, nbDiffs, err := DNSControlDiffByRecord(currentRecords, wantedRecords, domain)
if err != nil {
return nil, nbDiffs, fmt.Errorf("unable to compute zone diff: %w", err)
}
// Build a lookup from happydns Record string → original libdns records (with ProviderData).
// This ensures delete operations use the provider's record IDs.
libdnsRecordsByKey := make(map[string][]libdns.Record)
for _, rec := range currentLibdnsRecs {
rr := rec.RR()
key := fmt.Sprintf("%s\t%s\t%s", rr.Name, rr.Type, rr.Data)
libdnsRecordsByKey[key] = append(libdnsRecordsByKey[key], rec)
}
// Step 3: Create corrections with executable F closures.
corrections := make([]*happydns.Correction, len(diffs))
for i, diff := range diffs {
corrections[i] = &happydns.Correction{
Id: diff.Id,
Msg: diff.Msg,
Kind: diff.Kind,
OldRecords: diff.OldRecords,
NewRecords: diff.NewRecords,
}
corrections[i].F = p.makeCorrectionFunc(zone, diff, libdnsRecordsByKey)
}
return corrections, nbDiffs, nil
}
// makeCorrectionFunc creates an executable function for a single correction.
func (p *LibdnsAdapterNSProvider) makeCorrectionFunc(
zone string,
diff *happydns.Correction,
libdnsRecordsByKey map[string][]libdns.Record,
) func() error {
kind := diff.Kind
// Resolve old records to their original libdns Records (with ProviderData).
oldRecs := p.resolveOriginalRecords(diff.OldRecords, zone, libdnsRecordsByKey)
newRecs := happyDNSRecordsToLibdnsRecords(diff.NewRecords, zone)
// If we have both appender and deleter, use granular operations.
if p.appender != nil && p.deleter != nil {
return func() error {
ctx := context.TODO()
switch kind {
case happydns.CorrectionKindAddition:
_, err := p.appender.AppendRecords(ctx, zone, newRecs)
return err
case happydns.CorrectionKindDeletion:
_, err := p.deleter.DeleteRecords(ctx, zone, oldRecs)
return err
case happydns.CorrectionKindUpdate:
if _, err := p.deleter.DeleteRecords(ctx, zone, oldRecs); err != nil {
return fmt.Errorf("delete phase of update: %w", err)
}
_, err := p.appender.AppendRecords(ctx, zone, newRecs)
if err != nil {
return fmt.Errorf("append phase of update: %w", err)
}
return nil
}
return nil
}
}
// Fallback: use SetRecords if available.
if p.setter != nil {
return func() error {
ctx := context.TODO()
switch kind {
case happydns.CorrectionKindAddition:
// SetRecords with the new records will add them to the zone
// for their (name, type) pair.
_, err := p.setter.SetRecords(ctx, zone, newRecs)
return err
case happydns.CorrectionKindDeletion:
// To delete, we need to set the (name, type) pair to empty.
// DeleteRecords would be better, but we only have SetRecords.
// Use DeleteRecords-style wildcard via setter: set with empty set
// is not directly possible with SetRecords semantics.
// Fall through to delete if we have deleter, otherwise error.
return fmt.Errorf("cannot delete records: provider only supports SetRecords, not DeleteRecords")
case happydns.CorrectionKindUpdate:
// SetRecords replaces all records for the (name, type) pair.
_, err := p.setter.SetRecords(ctx, zone, newRecs)
return err
}
return nil
}
}
return func() error {
return fmt.Errorf("libdns provider does not support record modification")
}
}
// resolveOriginalRecords tries to find the original libdns Records (with ProviderData)
// for the given happydns Records. This ensures that delete operations use the provider's
// record identifiers.
func (p *LibdnsAdapterNSProvider) resolveOriginalRecords(
records []happydns.Record,
zone string,
libdnsRecordsByKey map[string][]libdns.Record,
) []libdns.Record {
result := make([]libdns.Record, 0, len(records))
for _, rec := range records {
rr := happyDNSRecordToLibdnsRR(rec, zone)
key := fmt.Sprintf("%s\t%s\t%s", rr.Name, rr.Type, rr.Data)
if originals, ok := libdnsRecordsByKey[key]; ok && len(originals) > 0 {
// Use the original record and consume it from the map.
result = append(result, originals[0])
libdnsRecordsByKey[key] = originals[1:]
} else {
// Fallback: use the converted RR (without ProviderData).
result = append(result, rr)
}
}
return result
}
// happyDNSRecordsToLibdnsRecords converts happydns Records to libdns Records (the interface).
func happyDNSRecordsToLibdnsRecords(rrs []happydns.Record, zone string) []libdns.Record {
result := make([]libdns.Record, len(rrs))
for i, rr := range rrs {
result[i] = happyDNSRecordToLibdnsRR(rr, zone)
}
return result
}

View file

@ -0,0 +1,331 @@
// 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 adapter
import (
"context"
"net/netip"
"testing"
"time"
"github.com/libdns/libdns"
"github.com/miekg/dns"
"git.happydns.org/happyDomain/model"
)
// mockLibdnsProvider implements RecordGetter, RecordAppender, RecordDeleter for testing.
type mockLibdnsProvider struct {
records []libdns.Record
appended []libdns.Record
deleted []libdns.Record
zones []libdns.Zone
appendErr error
deleteErr error
getErr error
listZoneErr error
}
func (m *mockLibdnsProvider) GetRecords(_ context.Context, _ string) ([]libdns.Record, error) {
if m.getErr != nil {
return nil, m.getErr
}
return m.records, nil
}
func (m *mockLibdnsProvider) AppendRecords(_ context.Context, _ string, recs []libdns.Record) ([]libdns.Record, error) {
if m.appendErr != nil {
return nil, m.appendErr
}
m.appended = append(m.appended, recs...)
return recs, nil
}
func (m *mockLibdnsProvider) DeleteRecords(_ context.Context, _ string, recs []libdns.Record) ([]libdns.Record, error) {
if m.deleteErr != nil {
return nil, m.deleteErr
}
m.deleted = append(m.deleted, recs...)
return recs, nil
}
func (m *mockLibdnsProvider) ListZones(_ context.Context) ([]libdns.Zone, error) {
if m.listZoneErr != nil {
return nil, m.listZoneErr
}
return m.zones, nil
}
// mockLibdnsConfig implements LibdnsConfigAdapter.
type mockLibdnsConfig struct {
provider any
}
func (m *mockLibdnsConfig) LibdnsProvider() any {
return m.provider
}
func (m *mockLibdnsConfig) InstantiateProvider() (happydns.ProviderActuator, error) {
return NewLibdnsProviderAdapter(m)
}
func TestNewLibdnsProviderAdapter(t *testing.T) {
mock := &mockLibdnsProvider{}
config := &mockLibdnsConfig{provider: mock}
adapter, err := NewLibdnsProviderAdapter(config)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !adapter.CanListZones() {
t.Error("expected CanListZones to be true")
}
if adapter.CanCreateDomain() {
t.Error("expected CanCreateDomain to be false")
}
}
func TestLibdnsAdapter_GetZoneRecords(t *testing.T) {
mock := &mockLibdnsProvider{
records: []libdns.Record{
libdns.Address{
Name: "www",
TTL: 300 * time.Second,
IP: netip.MustParseAddr("192.0.2.1"),
},
libdns.TXT{
Name: "@",
TTL: 300 * time.Second,
Text: "v=spf1 ~all",
},
},
}
config := &mockLibdnsConfig{provider: mock}
adapter, err := NewLibdnsProviderAdapter(config)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
records, err := adapter.GetZoneRecords("example.com.")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(records) != 2 {
t.Fatalf("expected 2 records, got %d", len(records))
}
// Check A record
if records[0].Header().Rrtype != dns.TypeA {
t.Errorf("expected first record to be A, got %s", dns.TypeToString[records[0].Header().Rrtype])
}
// Check TXT record
txt, ok := records[1].(*happydns.TXT)
if !ok {
t.Fatalf("expected second record to be *happydns.TXT, got %T", records[1])
}
if txt.Txt != "v=spf1 ~all" {
t.Errorf("expected TXT 'v=spf1 ~all', got %q", txt.Txt)
}
}
func TestLibdnsAdapter_ListZones(t *testing.T) {
mock := &mockLibdnsProvider{
zones: []libdns.Zone{
{Name: "example.com."},
{Name: "example.org."},
},
}
config := &mockLibdnsConfig{provider: mock}
adapter, err := NewLibdnsProviderAdapter(config)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
zones, err := adapter.ListZones()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(zones) != 2 {
t.Fatalf("expected 2 zones, got %d", len(zones))
}
if zones[0] != "example.com." {
t.Errorf("expected first zone 'example.com.', got %q", zones[0])
}
}
func TestLibdnsAdapter_GetZoneCorrections_NoChanges(t *testing.T) {
records := []libdns.Record{
libdns.Address{
Name: "www",
TTL: 300 * time.Second,
IP: netip.MustParseAddr("192.0.2.1"),
},
}
mock := &mockLibdnsProvider{records: records}
config := &mockLibdnsConfig{provider: mock}
adapter, err := NewLibdnsProviderAdapter(config)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Pass the same records as wanted
aRR, _ := dns.NewRR("www.example.com. 300 IN A 192.0.2.1")
corrections, _, err := adapter.GetZoneCorrections("example.com.", []happydns.Record{aRR})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(corrections) != 0 {
t.Errorf("expected 0 corrections, got %d", len(corrections))
}
}
func TestLibdnsAdapter_GetZoneCorrections_Addition(t *testing.T) {
// Provider has one A record, we want to add a CNAME.
mock := &mockLibdnsProvider{
records: []libdns.Record{
libdns.Address{
Name: "www",
TTL: 300 * time.Second,
IP: netip.MustParseAddr("192.0.2.1"),
},
},
}
config := &mockLibdnsConfig{provider: mock}
adapter, err := NewLibdnsProviderAdapter(config)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
aRR, _ := dns.NewRR("www.example.com. 300 IN A 192.0.2.1")
cnameRR, _ := dns.NewRR("blog.example.com. 300 IN CNAME www.example.com.")
corrections, _, err := adapter.GetZoneCorrections("example.com.", []happydns.Record{aRR, cnameRR})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(corrections) == 0 {
t.Fatal("expected at least 1 correction")
}
// Execute the correction
for _, c := range corrections {
if c.Kind == happydns.CorrectionKindAddition {
if err := c.F(); err != nil {
t.Fatalf("unexpected error executing correction: %v", err)
}
}
}
if len(mock.appended) == 0 {
t.Error("expected records to be appended")
}
}
func TestLibdnsAdapter_GetZoneCorrections_Deletion(t *testing.T) {
// Provider has two records, we want only one.
mock := &mockLibdnsProvider{
records: []libdns.Record{
libdns.Address{
Name: "www",
TTL: 300 * time.Second,
IP: netip.MustParseAddr("192.0.2.1"),
},
libdns.Address{
Name: "old",
TTL: 300 * time.Second,
IP: netip.MustParseAddr("192.0.2.2"),
},
},
}
config := &mockLibdnsConfig{provider: mock}
adapter, err := NewLibdnsProviderAdapter(config)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
aRR, _ := dns.NewRR("www.example.com. 300 IN A 192.0.2.1")
corrections, _, err := adapter.GetZoneCorrections("example.com.", []happydns.Record{aRR})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(corrections) == 0 {
t.Fatal("expected at least 1 correction")
}
// Execute the deletion correction
for _, c := range corrections {
if c.Kind == happydns.CorrectionKindDeletion {
if err := c.F(); err != nil {
t.Fatalf("unexpected error executing correction: %v", err)
}
}
}
if len(mock.deleted) == 0 {
t.Error("expected records to be deleted")
}
}
func TestGetLibdnsProviderCapabilities(t *testing.T) {
mock := &mockLibdnsProvider{}
config := &mockLibdnsConfig{provider: mock}
caps := GetLibdnsProviderCapabilities(config)
// Should include ListDomains since mock implements ZoneLister
found := false
for _, c := range caps {
if c == "ListDomains" {
found = true
break
}
}
if !found {
t.Error("expected ListDomains capability")
}
// Should include common RR types
expectedTypes := []string{"rr-1-A", "rr-28-AAAA", "rr-5-CNAME", "rr-15-MX", "rr-16-TXT"}
for _, expected := range expectedTypes {
found = false
for _, c := range caps {
if c == expected {
found = true
break
}
}
if !found {
t.Errorf("expected capability %s", expected)
}
}
}

View file

@ -0,0 +1,188 @@
// 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 adapter
import (
"fmt"
"strings"
"time"
"github.com/StackExchange/dnscontrol/v4/pkg/txtutil"
"github.com/libdns/libdns"
"github.com/miekg/dns"
"git.happydns.org/happyDomain/internal/helpers"
"git.happydns.org/happyDomain/model"
)
// libdnsToHappyDNSRecord converts a libdns Record to a happydns Record.
// The zone parameter should be the FQDN with trailing dot (e.g. "example.com.").
// For TXT records, it produces happydns.TXT directly (single concatenated string).
func libdnsToHappyDNSRecord(rec libdns.Record, zone string) (happydns.Record, error) {
rr := rec.RR()
fqdn := libdns.AbsoluteName(rr.Name, zone)
if !strings.HasSuffix(fqdn, ".") {
fqdn += "."
}
ttlSec := uint32(rr.TTL.Seconds())
// For TXT records, the libdns Data field may be either raw text or
// RFC1035 presentation-format with quotes and escaping (depends on provider).
// Use txtutil.ParseQuoted to decode presentation-format data.
if rr.Type == "TXT" {
return &happydns.TXT{
Hdr: dns.RR_Header{
Name: fqdn,
Rrtype: dns.TypeTXT,
Class: dns.ClassINET,
Ttl: ttlSec,
},
Txt: decodeTXTData(rr.Data),
}, nil
}
// For SPF records (if any provider returns them)
if rr.Type == "SPF" {
return &happydns.SPF{
Hdr: dns.RR_Header{
Name: fqdn,
Rrtype: dns.TypeSPF,
Class: dns.ClassINET,
Ttl: ttlSec,
},
Txt: decodeTXTData(rr.Data),
}, nil
}
// For all other record types, build a zone-file line and parse it.
line := fmt.Sprintf("%s %d IN %s %s", fqdn, ttlSec, rr.Type, rr.Data)
return helpers.ParseRecord(line, zone)
}
// happyDNSRecordToLibdnsRR converts a happydns Record to a libdns RR.
// The zone parameter should be the FQDN with trailing dot (e.g. "example.com.").
func happyDNSRecordToLibdnsRR(record happydns.Record, zone string) libdns.RR {
hdr := record.Header()
name := libdns.RelativeName(hdr.Name, zone)
typStr := dns.TypeToString[hdr.Rrtype]
ttl := time.Duration(hdr.Ttl) * time.Second
// For happydns.TXT / happydns.SPF, extract the raw text directly.
if txt, ok := record.(*happydns.TXT); ok {
return libdns.RR{
Name: name,
TTL: ttl,
Type: typStr,
Data: txt.Txt,
}
}
if spf, ok := record.(*happydns.SPF); ok {
return libdns.RR{
Name: name,
TTL: ttl,
Type: typStr,
Data: spf.Txt,
}
}
// For ConvertibleRecord types, convert to dns.RR first.
var dnsRR dns.RR
if cr, ok := record.(happydns.ConvertibleRecord); ok {
dnsRR = cr.ToRR()
} else if rr, ok := record.(dns.RR); ok {
dnsRR = rr
} else {
// Fallback: try to extract rdata from string representation.
return libdns.RR{
Name: name,
TTL: ttl,
Type: typStr,
Data: extractRdata(record.String(), typStr),
}
}
return libdns.RR{
Name: name,
TTL: ttl,
Type: typStr,
Data: extractRdata(dnsRR.String(), typStr),
}
}
// decodeTXTData decodes TXT record data that may be in RFC1035 presentation
// format (quoted, with escaping) or raw text. Some libdns providers (e.g.
// PowerDNS) return quoted data like `"value"`, while others (e.g. libdns.TXT)
// return raw unquoted text. ParseQuoted handles quoted data correctly but
// treats unquoted spaces as separators, so we only use it when quotes are present.
func decodeTXTData(s string) string {
if strings.ContainsRune(s, '"') {
if decoded, err := txtutil.ParseQuoted(s); err == nil {
return decoded
}
}
return s
}
// extractRdata extracts the rdata portion from a miekg/dns RR string.
// The format is: "name.\t<TTL>\tIN\t<TYPE>\t<rdata...>"
func extractRdata(rrString string, rrType string) string {
// miekg/dns uses tab-separated fields
marker := "\tIN\t" + rrType + "\t"
idx := strings.Index(rrString, marker)
if idx != -1 {
return rrString[idx+len(marker):]
}
// Fallback: try space-separated (shouldn't happen with miekg/dns)
marker = " IN " + rrType + " "
idx = strings.Index(rrString, marker)
if idx != -1 {
return rrString[idx+len(marker):]
}
return ""
}
// libdnsRecordsToHappyDNS converts a slice of libdns Records to happydns Records.
func libdnsRecordsToHappyDNS(recs []libdns.Record, zone string) ([]happydns.Record, error) {
result := make([]happydns.Record, 0, len(recs))
for _, rec := range recs {
hdr, err := libdnsToHappyDNSRecord(rec, zone)
if err != nil {
return nil, fmt.Errorf("converting libdns record %v: %w", rec.RR(), err)
}
result = append(result, hdr)
}
return result, nil
}
// happyDNSRecordsToLibdns converts a slice of happydns Records to libdns RR values.
func happyDNSRecordsToLibdns(rrs []happydns.Record, zone string) []libdns.RR {
result := make([]libdns.RR, len(rrs))
for i, rr := range rrs {
result[i] = happyDNSRecordToLibdnsRR(rr, zone)
}
return result
}

View file

@ -0,0 +1,343 @@
// 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 adapter
import (
"net/netip"
"testing"
"time"
"github.com/libdns/libdns"
"github.com/miekg/dns"
"git.happydns.org/happyDomain/model"
)
func TestLibdnsToHappyDNS_A(t *testing.T) {
rec := libdns.Address{}
rec.Name = "www"
rec.TTL = 300 * time.Second
rec.IP = mustParseAddr("192.0.2.1")
result, err := libdnsToHappyDNSRecord(rec, "example.com.")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Header().Name != "www.example.com." {
t.Errorf("expected name www.example.com., got %s", result.Header().Name)
}
if result.Header().Rrtype != dns.TypeA {
t.Errorf("expected type A, got %s", dns.TypeToString[result.Header().Rrtype])
}
if result.Header().Ttl != 300 {
t.Errorf("expected TTL 300, got %d", result.Header().Ttl)
}
}
func TestLibdnsToHappyDNS_AAAA(t *testing.T) {
rec := libdns.Address{}
rec.Name = "@"
rec.TTL = 600 * time.Second
rec.IP = mustParseAddr("2001:db8::1")
result, err := libdnsToHappyDNSRecord(rec, "example.com.")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Header().Name != "example.com." {
t.Errorf("expected name example.com., got %s", result.Header().Name)
}
if result.Header().Rrtype != dns.TypeAAAA {
t.Errorf("expected type AAAA, got %s", dns.TypeToString[result.Header().Rrtype])
}
}
func TestLibdnsToHappyDNS_TXT(t *testing.T) {
rec := libdns.TXT{
Name: "@",
TTL: 300 * time.Second,
Text: "v=spf1 include:_spf.google.com ~all",
}
result, err := libdnsToHappyDNSRecord(rec, "example.com.")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
txt, ok := result.(*happydns.TXT)
if !ok {
t.Fatalf("expected *happydns.TXT, got %T", result)
}
if txt.Txt != "v=spf1 include:_spf.google.com ~all" {
t.Errorf("expected TXT value 'v=spf1 include:_spf.google.com ~all', got %q", txt.Txt)
}
if txt.Hdr.Name != "example.com." {
t.Errorf("expected name example.com., got %s", txt.Hdr.Name)
}
}
func TestLibdnsToHappyDNS_CNAME(t *testing.T) {
rec := libdns.CNAME{
Name: "www",
TTL: 3600 * time.Second,
Target: "example.com.",
}
result, err := libdnsToHappyDNSRecord(rec, "example.com.")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Header().Rrtype != dns.TypeCNAME {
t.Errorf("expected type CNAME, got %s", dns.TypeToString[result.Header().Rrtype])
}
}
func TestLibdnsToHappyDNS_MX(t *testing.T) {
rec := libdns.MX{
Name: "@",
TTL: 3600 * time.Second,
Preference: 10,
Target: "mail.example.com.",
}
result, err := libdnsToHappyDNSRecord(rec, "example.com.")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Header().Rrtype != dns.TypeMX {
t.Errorf("expected type MX, got %s", dns.TypeToString[result.Header().Rrtype])
}
}
func TestHappyDNSToLibdns_A(t *testing.T) {
rr, _ := dns.NewRR("www.example.com. 300 IN A 192.0.2.1")
result := happyDNSRecordToLibdnsRR(rr, "example.com.")
if result.Name != "www" {
t.Errorf("expected name 'www', got %q", result.Name)
}
if result.Type != "A" {
t.Errorf("expected type A, got %s", result.Type)
}
if result.TTL != 300*time.Second {
t.Errorf("expected TTL 300s, got %v", result.TTL)
}
if result.Data != "192.0.2.1" {
t.Errorf("expected data '192.0.2.1', got %q", result.Data)
}
}
func TestHappyDNSToLibdns_TXT(t *testing.T) {
txt := &happydns.TXT{
Hdr: dns.RR_Header{
Name: "example.com.",
Rrtype: dns.TypeTXT,
Class: dns.ClassINET,
Ttl: 300,
},
Txt: "v=spf1 include:_spf.google.com ~all",
}
result := happyDNSRecordToLibdnsRR(txt, "example.com.")
if result.Name != "@" {
t.Errorf("expected name '@', got %q", result.Name)
}
if result.Type != "TXT" {
t.Errorf("expected type TXT, got %s", result.Type)
}
if result.Data != "v=spf1 include:_spf.google.com ~all" {
t.Errorf("expected data 'v=spf1 include:_spf.google.com ~all', got %q", result.Data)
}
}
func TestHappyDNSToLibdns_Apex(t *testing.T) {
rr, _ := dns.NewRR("example.com. 300 IN A 192.0.2.1")
result := happyDNSRecordToLibdnsRR(rr, "example.com.")
if result.Name != "@" {
t.Errorf("expected name '@', got %q", result.Name)
}
}
func TestRoundTrip_A(t *testing.T) {
original := libdns.Address{}
original.Name = "www"
original.TTL = 300 * time.Second
original.IP = mustParseAddr("192.0.2.1")
zone := "example.com."
hdRecord, err := libdnsToHappyDNSRecord(original, zone)
if err != nil {
t.Fatalf("unexpected error converting to happydns: %v", err)
}
roundtripped := happyDNSRecordToLibdnsRR(hdRecord, zone)
origRR := original.RR()
if roundtripped.Name != origRR.Name {
t.Errorf("name mismatch: got %q, want %q", roundtripped.Name, origRR.Name)
}
if roundtripped.Type != origRR.Type {
t.Errorf("type mismatch: got %q, want %q", roundtripped.Type, origRR.Type)
}
if roundtripped.TTL != origRR.TTL {
t.Errorf("TTL mismatch: got %v, want %v", roundtripped.TTL, origRR.TTL)
}
if roundtripped.Data != origRR.Data {
t.Errorf("data mismatch: got %q, want %q", roundtripped.Data, origRR.Data)
}
}
func TestRoundTrip_TXT(t *testing.T) {
original := libdns.TXT{
Name: "test",
TTL: 600 * time.Second,
Text: "hello world with spaces and special chars: @#$%",
}
zone := "example.com."
hdRecord, err := libdnsToHappyDNSRecord(original, zone)
if err != nil {
t.Fatalf("unexpected error converting to happydns: %v", err)
}
txt, ok := hdRecord.(*happydns.TXT)
if !ok {
t.Fatalf("expected *happydns.TXT, got %T", hdRecord)
}
if txt.Txt != original.Text {
t.Errorf("TXT text mismatch after first conversion: got %q, want %q", txt.Txt, original.Text)
}
roundtripped := happyDNSRecordToLibdnsRR(hdRecord, zone)
origRR := original.RR()
if roundtripped.Name != origRR.Name {
t.Errorf("name mismatch: got %q, want %q", roundtripped.Name, origRR.Name)
}
if roundtripped.Type != origRR.Type {
t.Errorf("type mismatch: got %q, want %q", roundtripped.Type, origRR.Type)
}
if roundtripped.Data != origRR.Data {
t.Errorf("data mismatch: got %q, want %q", roundtripped.Data, origRR.Data)
}
}
func TestLibdnsToHappyDNS_TXT_QuotedData(t *testing.T) {
// Some libdns providers (e.g. PowerDNS) return TXT data in RFC1035 presentation format.
tests := []struct {
name string
data string
expected string
}{
{"simple quoted", `"some-acme-challenge-value"`, "some-acme-challenge-value"},
{"escaped quote", `"foo\"bar"`, `foo"bar`},
{"escaped backslash", `"foo\\bar"`, `foo\bar`},
{"multi-chunk", `"chunk1" "chunk2"`, "chunk1chunk2"},
{"unquoted passthrough", "v=spf1 ~all", "v=spf1 ~all"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rec := libdns.RR{
Name: "_acme-challenge",
TTL: 3600 * time.Second,
Type: "TXT",
Data: tt.data,
}
result, err := libdnsToHappyDNSRecord(rec, "example.com.")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
txt, ok := result.(*happydns.TXT)
if !ok {
t.Fatalf("expected *happydns.TXT, got %T", result)
}
if txt.Txt != tt.expected {
t.Errorf("expected %q, got %q", tt.expected, txt.Txt)
}
})
}
}
func TestLibdnsToHappyDNS_TXT_UnquotedData(t *testing.T) {
// libdns.TXT returns raw unquoted text — should pass through unchanged.
rec := libdns.TXT{
Name: "@",
TTL: 300 * time.Second,
Text: "v=spf1 ~all",
}
result, err := libdnsToHappyDNSRecord(rec, "example.com.")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
txt, ok := result.(*happydns.TXT)
if !ok {
t.Fatalf("expected *happydns.TXT, got %T", result)
}
if txt.Txt != "v=spf1 ~all" {
t.Errorf("expected unquoted TXT value, got %q", txt.Txt)
}
}
func TestExtractRdata(t *testing.T) {
tests := []struct {
input string
rrType string
want string
}{
{"www.example.com.\t300\tIN\tA\t192.0.2.1", "A", "192.0.2.1"},
{"example.com.\t3600\tIN\tMX\t10 mail.example.com.", "MX", "10 mail.example.com."},
{"example.com.\t300\tIN\tAAAA\t2001:db8::1", "AAAA", "2001:db8::1"},
}
for _, tt := range tests {
got := extractRdata(tt.input, tt.rrType)
if got != tt.want {
t.Errorf("extractRdata(%q, %q) = %q, want %q", tt.input, tt.rrType, got, tt.want)
}
}
}
func mustParseAddr(s string) netip.Addr {
addr, err := netip.ParseAddr(s)
if err != nil {
panic(err)
}
return addr
}

View file

@ -184,7 +184,7 @@ func (pc *ProviderController) UpdateProvider(c *gin.Context) {
func (pc *ProviderController) ClearProviders(c *gin.Context) {
user := middleware.MyUser(c)
if user != nil {
providers, err := pc.providerService.ListUserProviders(user)
providers, err := pc.providerService.ListUserProviders(c.Request.Context(), user)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return

View file

@ -87,6 +87,23 @@ func (ac *AuthUserController) ChangePassword(c *gin.Context) {
ac.lc.Logout(c)
}
// IsAuthUser checks if the currently authenticated session matches the given user identifier.
//
// @Summary Check if current user
// @Schemes
// @Description Check if the currently authenticated session matches the given user identifier.
// @Tags users
// @Accept json
// @Produce json
// @Security securitydefinitions.basic
// @Param userId path string true "User identifier"
// @Success 204 {null} null
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
// @Router /users/{userId}/is_auth_user [get]
func (ac *AuthUserController) IsAuthUser(c *gin.Context) {
c.Status(http.StatusNoContent)
}
// DeleteAuthUser delete the account related to the given user.
//
// @Summary Drop account

View file

@ -106,7 +106,7 @@ func (dc *DomainController) AddDomain(c *gin.Context) {
return
}
err = dc.domainService.CreateDomain(user, &uz)
err = dc.domainService.CreateDomain(c.Request.Context(), user, &uz)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
@ -150,7 +150,7 @@ func (dc *DomainController) GetDomain(c *gin.Context) {
// @Accept json
// @Produce json
// @Param domainId path string true "Domain identifier"
// @Param body body happydns.Domain true "The new object overriding the current domain"
// @Param body body happydns.DomainUpdateInput true "The fields to update"
// @Security securitydefinitions.basic
// @Success 200 {object} happydns.Domain
// @Failure 400 {object} happydns.ErrorResponse "Invalid input"
@ -167,7 +167,7 @@ func (dc *DomainController) UpdateDomain(c *gin.Context) {
return
}
var domain happydns.Domain
var domain happydns.DomainUpdateInput
err := c.ShouldBindJSON(&domain)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})

View file

@ -51,7 +51,7 @@ func NewDomainLogController(domainLogService happydns.DomainLogUsecase) *DomainL
// @Produce json
// @Param domainId path string true "Domain identifier"
// @Security securitydefinitions.basic
// @Success 200 {object} []happydns.DomainLog
// @Success 200 {array} happydns.DomainLog
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
// @Failure 404 {object} happydns.ErrorResponse "Domain not found"
// @Router /domains/{domainId}/logs [get]

View file

@ -61,7 +61,7 @@ func (pc *ProviderController) ListProviders(c *gin.Context) {
return
}
providers, err := pc.providerService.ListUserProviders(user)
providers, err := pc.providerService.ListUserProviders(c.Request.Context(), user)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
@ -119,7 +119,7 @@ func (pc *ProviderController) AddProvider(c *gin.Context) {
return
}
provider, err := pc.providerService.CreateProvider(user, &usrc)
provider, err := pc.providerService.CreateProvider(c.Request.Context(), user, &usrc)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
@ -137,7 +137,7 @@ func (pc *ProviderController) AddProvider(c *gin.Context) {
// @Accept json
// @Produce json
// @Param providerId path string true "Provider identifier"
// @Param body body happydns.Provider true "The new object overriding the current provider"
// @Param body body happydns.ProviderMinimal true "The new object overriding the current provider"
// @Security securitydefinitions.basic
// @Success 200 {object} happydns.Provider
// @Failure 400 {object} happydns.ErrorResponse "Invalid input"
@ -157,7 +157,7 @@ func (pc *ProviderController) UpdateProvider(c *gin.Context) {
return
}
err = pc.providerService.UpdateProviderFromMessage(old.Id, user, &provider)
err = pc.providerService.UpdateProviderFromMessage(c.Request.Context(), old.Id, user, &provider)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
@ -191,13 +191,13 @@ func (pc *ProviderController) DeleteProvider(c *gin.Context) {
providermeta := c.MustGet("providermeta").(*happydns.ProviderMeta)
err := pc.providerService.DeleteProvider(user, providermeta.Id)
err := pc.providerService.DeleteProvider(c.Request.Context(), user, providermeta.Id)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusNoContent, nil)
c.Status(http.StatusNoContent)
}
// GetDomainsHostedByProvider lists domains available to management from the given Provider.
@ -210,7 +210,7 @@ func (pc *ProviderController) DeleteProvider(c *gin.Context) {
// @Produce json
// @Param providerId path string true "Provider identifier"
// @Security securitydefinitions.basic
// @Success 200 {object} happydns.Provider
// @Success 200 {array} string
// @Failure 400 {object} happydns.ErrorResponse "Unable to instantiate the provider"
// @Failure 400 {object} happydns.ErrorResponse "The provider doesn't support domain listing"
// @Failure 400 {object} happydns.ErrorResponse "Provider error"
@ -220,7 +220,7 @@ func (pc *ProviderController) DeleteProvider(c *gin.Context) {
func (pc *ProviderController) GetDomainsHostedByProvider(c *gin.Context) {
provider := c.MustGet("provider").(*happydns.Provider)
domains, err := pc.providerService.ListHostedDomains(provider)
domains, err := pc.providerService.ListHostedDomains(c.Request.Context(), provider)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
return
@ -238,8 +238,9 @@ func (pc *ProviderController) GetDomainsHostedByProvider(c *gin.Context) {
// @Accept json
// @Produce json
// @Param providerId path string true "Provider identifier"
// @Param fqdn path string true "Fully qualified domain name"
// @Security securitydefinitions.basic
// @Success 200 {object} happydns.Provider
// @Success 204 "Domain created successfully"
// @Failure 400 {object} happydns.ErrorResponse "Unable to instantiate the provider"
// @Failure 400 {object} happydns.ErrorResponse "The provider doesn't support domain listing"
// @Failure 400 {object} happydns.ErrorResponse "Provider error"
@ -249,11 +250,11 @@ func (pc *ProviderController) GetDomainsHostedByProvider(c *gin.Context) {
func (pc *ProviderController) CreateDomainOnProvider(c *gin.Context) {
provider := c.MustGet("provider").(*happydns.Provider)
err := pc.providerService.CreateDomainOnProvider(provider, c.Param("fqdn"))
err := pc.providerService.CreateDomainOnProvider(c.Request.Context(), provider, c.Param("fqdn"))
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
return
}
c.JSON(http.StatusOK, true)
c.Status(http.StatusNoContent)
}

View file

@ -54,7 +54,7 @@ func NewProviderSettingsController(pSettingsServices happydns.ProviderSettingsUs
// @Param body body happydns.ProviderSettingsState true "The current state of the Provider's settings, possibly empty (but not null)"
// @Security securitydefinitions.basic
// @Success 200 {object} happydns.Provider "The Provider has been created with the given settings"
// @Success 202 {object} happydns.ProviderSettingsResponse "The settings need more rafinement"
// @Success 202 {object} happydns.ProviderSettingsResponse "The settings need more refinement"
// @Failure 400 {object} happydns.ErrorResponse "Invalid input"
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
// @Failure 404 {object} happydns.ErrorResponse "Provider not found"
@ -78,7 +78,7 @@ func (psc *ProviderSettingsController) NextProviderSettingsState(c *gin.Context)
return
}
provider, form, err := psc.pSettingsServices.NextProviderSettingsState(&uss, pType, user)
provider, form, err := psc.pSettingsServices.NextProviderSettingsState(c.Request.Context(), &uss, pType, user)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return

View file

@ -42,32 +42,6 @@ func NewResolverController(resolverService happydns.ResolverUsecase) *ResolverCo
}
}
// DNSMsg is the documentation struct corresponding to dns.Msg
type DNSMsg struct {
// Question is the Question section of the DNS response.
Question []DNSQuestion
// Answer is the list of Answer records in the DNS response.
Answer []any `swaggertype:"object"`
// Ns is the list of Authoritative records in the DNS response.
Ns []any `swaggertype:"object"`
// Extra is the list of extra records in the DNS response.
Extra []any `swaggertype:"object"`
}
type DNSQuestion struct {
// Name is the domain name researched.
Name string
// Qtype is the type of record researched.
Qtype uint16
// Qclass is the class of record researched.
Qclass uint16
}
// RunResolver performs a NS resolution for a given domain, with options.
//
// @Summary Perform a DNS resolution.
@ -77,8 +51,7 @@ type DNSQuestion struct {
// @Accept json
// @Produce json
// @Param body body happydns.ResolverRequest true "Options to the resolution"
// @Success 200 {object} DNSMsg
// @Success 204 {object} happydns.ErrorResponse "No content"
// @Success 200 {object} happydns.ResolverResponse
// @Failure 400 {object} happydns.ErrorResponse "Invalid input"
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
// @Failure 403 {object} happydns.ErrorResponse "The resolver refused to treat our request"
@ -100,5 +73,5 @@ func (rc *ResolverController) RunResolver(c *gin.Context) {
return
}
c.JSON(http.StatusOK, r)
c.JSON(http.StatusOK, happydns.NewResolverResponseFromMsg(r))
}

View file

@ -56,9 +56,10 @@ func NewServiceController(duService happydns.ZoneServiceUsecase, suService happy
// @Accept json
// @Produce json
// @Security securitydefinitions.basic
// @Param domainId path string true "Domain identifier"
// @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 domainId path string true "Domain identifier"
// @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 body body happydns.Service true "Service to add"
// @Success 200 {object} happydns.Zone
// @Failure 400 {object} happydns.ErrorResponse "Invalid input"
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"

View file

@ -145,6 +145,7 @@ func (ssc *ServiceSpecsController) InitializeServiceSpec(c *gin.Context) {
// @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"
// @Param body body happydns.Service true "The service configuration to generate records for"
// @Success 200 {array} happydns.Record
// @Failure 400 {object} happydns.ErrorResponse "Invalid request body"
// @Failure 404 {object} happydns.ErrorResponse "Service type does not exist"

View file

@ -68,9 +68,9 @@ func (sc *SessionController) SessionHandler(c *gin.Context) {
// @Description Get the content of the current user's session.
// @Tags users
// @Accept json
// @Prodsce json
// @Produce json
// @Security securitydefinitions.basic
// @Ssccess 200 {object} happydns.Session
// @Success 200 {object} happydns.Session
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
// @Router /session [get]
func (sc *SessionController) GetSession(c *gin.Context) {
@ -97,9 +97,9 @@ func (sc *SessionController) GetSession(c *gin.Context) {
// @Description Remove the content of the current user's session.
// @Tags users
// @Accept json
// @Prodsce json
// @Produce json
// @Security securitydefinitions.basic
// @Ssccess 204
// @Success 204
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
// @Router /session [delete]
func (sc *SessionController) ClearSession(c *gin.Context) {
@ -117,9 +117,9 @@ func (sc *SessionController) ClearSession(c *gin.Context) {
// @Description Closes all sessions for a given user.
// @Tags users
// @Accept json
// @Prodsce json
// @Produce json
// @Security securitydefinitions.basic
// @Ssccess 204 {null} null
// @Success 204
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
// @Router /sessions [delete]
func (sc *SessionController) ClearUserSessions(c *gin.Context) {
@ -145,9 +145,9 @@ func (sc *SessionController) ClearUserSessions(c *gin.Context) {
// @Description List the sessions open for the current user
// @Tags users
// @Accept json
// @Prodsce json
// @Produce json
// @Security securitydefinitions.basic
// @Ssccess 200 {object} happydns.Session
// @Success 200 {array} happydns.Session
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
// @Router /sessions [get]
func (sc *SessionController) GetSessions(c *gin.Context) {
@ -173,13 +173,14 @@ func (sc *SessionController) GetSessions(c *gin.Context) {
// @Description Create a new session for the current user.
// @Tags users
// @Accept json
// @Prodsce json
// @Produce json
// @Security securitydefinitions.basic
// @Ssccess 200 {object} happydns.Session
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
// @Param body body happydns.SessionInput true "Session to create"
// @Success 200 {object} happydns.Session
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
// @Router /sessions [post]
func (sc *SessionController) CreateSession(c *gin.Context) {
var us happydns.Session
var us happydns.SessionInput
err := c.ShouldBindJSON(&us)
if err != nil {
log.Printf("%s sends invalid Session JSON: %s", c.ClientIP(), err.Error())
@ -199,7 +200,7 @@ func (sc *SessionController) CreateSession(c *gin.Context) {
return
}
c.JSON(http.StatusOK, gin.H{"id": sess.Id})
c.JSON(http.StatusOK, sess)
}
// UpdateSession update a session owned by the current user
@ -209,14 +210,15 @@ func (sc *SessionController) CreateSession(c *gin.Context) {
// @Description Update a session owned by the current user.
// @Tags users
// @Accept json
// @Param sessionId path string true "Session identifier"
// @Prodsce json
// @Produce json
// @Security securitydefinitions.basic
// @Ssccess 200 {object} happydns.Session
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
// @Param sessionId path string true "Session identifier"
// @Param body body happydns.SessionInput true "Session fields to update"
// @Success 200 {object} happydns.Session
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
// @Router /sessions/{sessionId} [put]
func (sc *SessionController) UpdateSession(c *gin.Context) {
var us happydns.Session
var us happydns.SessionInput
err := c.ShouldBindJSON(&us)
if err != nil {
log.Printf("%s sends invalid Session JSON: %s", c.ClientIP(), err.Error())

View file

@ -53,7 +53,8 @@ func NewUserController(userService happydns.UserUsecase, lc *LoginController) *U
// @Tags users
// @Accept json
// @Produce json
// @Success 200 {object} happydns.User "The created user"
// @Param userId path string true "User identifier"
// @Success 200 {object} happydns.User "The user"
// @Failure 500 {object} happydns.ErrorResponse
// @Router /users/{userId} [get]
func (uc *UserController) GetUser(c *gin.Context) {

View file

@ -145,7 +145,7 @@ func (zc *ZoneController) DiffZonesHandler(c *gin.Context) {
// @Param domainId path string true "Domain identifier"
// @Param zoneId path string true "Zone identifier to use as the new one."
// @Param oldZoneId path string true "Zone identifier to use as the old one. Currently only @ are expected, to use the currently deployed zone."
// @Success 200 {object} []happydns.Correction "Differences, reported as text, one diff per item"
// @Success 200 {array} happydns.Correction "Differences, reported as text, one diff per item"
// @Failure 400 {object} happydns.ErrorResponse "Invalid input"
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
// @Failure 404 {object} happydns.ErrorResponse "Domain not found"
@ -194,7 +194,7 @@ func (zc *ZoneController) DiffZonesSummary(c *gin.Context) {
// @Security securitydefinitions.basic
// @Param domainId path string true "Domain identifier"
// @Param zoneId path string true "Zone identifier"
// @Param body body []string true "Differences (from /diff_zones) to apply"
// @Param body body happydns.ApplyZoneForm true "Differences to apply with commit message"
// @Success 200 {object} happydns.ZoneMeta "The new Zone metadata containing the current zone"
// @Failure 400 {object} happydns.ErrorResponse "Invalid input"
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"

View file

@ -47,7 +47,7 @@ func ProviderMetaHandler(providerService happydns.ProviderUsecase) gin.HandlerFu
}
// Retrieve provider meta
providermeta, err := providerService.GetUserProviderMeta(user, pid)
providermeta, err := providerService.GetUserProviderMeta(c.Request.Context(), user, pid)
if err != nil {
ErrorResponse(c, http.StatusNotFound, fmt.Errorf("provider not found"))
return
@ -77,7 +77,7 @@ func ProviderHandler(providerService happydns.ProviderUsecase) gin.HandlerFunc {
}
// Retrieve provider
provider, err := providerService.GetUserProvider(user, pid)
provider, err := providerService.GetUserProvider(c.Request.Context(), user, pid)
if err != nil {
ErrorResponse(c, http.StatusNotFound, fmt.Errorf("provider not found"))
return

View file

@ -27,13 +27,13 @@ import (
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/providers"
providerReg "git.happydns.org/happyDomain/internal/provider"
)
func ProviderSpecsHandler(c *gin.Context) {
psid := string(c.Param("psid"))
pbody, err := providers.FindProvider(psid)
pbody, err := providerReg.FindProvider(psid)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": fmt.Sprintf("Unable to find provider: %s", err.Error())})
return

View file

@ -28,13 +28,13 @@ import (
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/services"
intsvc "git.happydns.org/happyDomain/internal/service"
)
func ServiceSpecsHandler(c *gin.Context) {
ssid := string(c.Param("ssid"))
svc, err := svcs.FindSubService(ssid)
svc, err := intsvc.FindSubService(ssid)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": fmt.Sprintf("Unable to find specs: %s", err.Error())})
return

View file

@ -22,8 +22,6 @@
package route
import (
"net/http"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/controller"
@ -36,9 +34,7 @@ func DeclareAuthUserRoutes(router *gin.RouterGroup, authUserUC happydns.AuthUser
apiUserAuthRoutes := router.Group("/users/:uid")
apiUserAuthRoutes.Use(middleware.AuthUserHandler(authUserUC))
apiUserAuthRoutes.GET("/is_auth_user", func(c *gin.Context) {
c.Status(http.StatusNoContent)
})
apiUserAuthRoutes.GET("/is_auth_user", ac.IsAuthUser)
apiUserAuthRoutes.POST("/delete", ac.DeleteAuthUser)
apiUserAuthRoutes.POST("/new_password", ac.ChangePassword)

View file

@ -93,6 +93,9 @@ 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.setupRouter()
@ -108,6 +111,9 @@ 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.setupRouter()

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

@ -0,0 +1,140 @@
// 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"
sdk "git.happydns.org/checker-sdk-go/checker"
"git.happydns.org/happyDomain/internal/checker"
)
// 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{
loadCheckerPlugin,
}
// loadCheckerPlugin handles the NewCheckerPlugin symbol exported by checkers
// built against checker-sdk-go (see ../../checker-dummy/README.md).
func loadCheckerPlugin(p *plugin.Plugin, fname string) (bool, error) {
sym, err := p.Lookup("NewCheckerPlugin")
if err != nil {
// Symbol not present in this .so — not an error.
return false, nil
}
factory, ok := sym.(func() (*sdk.CheckerDefinition, sdk.ObservationProvider, error))
if !ok {
return true, fmt.Errorf("symbol NewCheckerPlugin has unexpected type %T", sym)
}
def, provider, err := factory()
if err != nil {
return true, err
}
if def == nil {
return true, fmt.Errorf("NewCheckerPlugin returned a nil CheckerDefinition")
}
if provider == nil {
return true, fmt.Errorf("NewCheckerPlugin returned a nil ObservationProvider")
}
checker.RegisterObservationProvider(provider)
checker.RegisterExternalizableChecker(def)
log.Printf("Plugin %s (%s) loaded", def.ID, fname)
return true, nil
}
// initPlugins scans each directory listed in cfg.PluginsDirectories and loads
// every .so file found as a Go plugin. A directory that cannot be read is a
// fatal configuration error; individual plugin failures are logged and
// skipped so that one bad .so 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())
if err := loadPlugin(fname); 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, because the file might be a valid plugin for a future version of
// happyDomain. The first loader error 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

@ -0,0 +1,48 @@
// 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 checker
import (
"strings"
"git.happydns.org/happyDomain/model"
)
// WorstStatusAggregator aggregates check states by taking the worst status.
type WorstStatusAggregator struct{}
func (a WorstStatusAggregator) Aggregate(states []happydns.CheckState) happydns.CheckState {
worst := happydns.StatusOK
var messages []string
for _, s := range states {
if s.Status > worst {
worst = s.Status
}
if s.Message != "" {
messages = append(messages, s.Message)
}
}
return happydns.CheckState{
Status: worst,
Message: strings.Join(messages, "; "),
}
}

View file

@ -0,0 +1,243 @@
// 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 checker
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
"git.happydns.org/happyDomain/model"
)
// The observation provider registry lives in the Apache-2.0 licensed
// checker-sdk-go module, so external plugins can register themselves
// without depending on AGPL code. These wrappers preserve the existing
// happyDomain call sites.
// RegisterObservationProvider registers an observation provider globally.
func RegisterObservationProvider(p happydns.ObservationProvider) {
sdk.RegisterObservationProvider(p)
}
// GetObservationProvider returns the provider for the given key, or nil.
func GetObservationProvider(key happydns.ObservationKey) happydns.ObservationProvider {
return sdk.FindObservationProvider(key)
}
// GetObservationProviders returns all registered observation providers.
func GetObservationProviders() map[happydns.ObservationKey]happydns.ObservationProvider {
return sdk.GetObservationProviders()
}
// ObservationCacheLookup resolves a cached observation for a target+key.
// Returns the raw data and collection time, or an error if not cached.
type ObservationCacheLookup func(target happydns.CheckTarget, key happydns.ObservationKey) (json.RawMessage, time.Time, error)
// ObservationContext provides lazy-loading, cached, thread-safe access to observation data.
// Collected data is serialized to json.RawMessage immediately after collection.
type ObservationContext struct {
target happydns.CheckTarget
opts happydns.CheckerOptions
cache map[happydns.ObservationKey]json.RawMessage
errors map[happydns.ObservationKey]error
mu sync.RWMutex
cacheLookup ObservationCacheLookup // nil = no DB cache
freshness time.Duration // 0 = always collect
providerOverride map[happydns.ObservationKey]happydns.ObservationProvider
}
// NewObservationContext creates a new ObservationContext for the given target and options.
// cacheLookup and freshness enable cross-checker observation reuse from stored snapshots.
// Pass nil and 0 to disable DB-based caching.
func NewObservationContext(target happydns.CheckTarget, opts happydns.CheckerOptions, cacheLookup ObservationCacheLookup, freshness time.Duration) *ObservationContext {
return &ObservationContext{
target: target,
opts: opts,
cache: make(map[happydns.ObservationKey]json.RawMessage),
errors: make(map[happydns.ObservationKey]error),
cacheLookup: cacheLookup,
freshness: freshness,
}
}
// SetProviderOverride registers a per-context provider that takes precedence
// over the global registry for the given observation key. This is used to
// substitute local providers with HTTP-backed ones when an endpoint is configured.
func (oc *ObservationContext) SetProviderOverride(key happydns.ObservationKey, p happydns.ObservationProvider) {
if oc.providerOverride == nil {
oc.providerOverride = make(map[happydns.ObservationKey]happydns.ObservationProvider)
}
oc.providerOverride[key] = p
}
// getProvider returns the observation provider for the given key, checking
// per-context overrides first, then falling back to the global registry.
func (oc *ObservationContext) getProvider(key happydns.ObservationKey) happydns.ObservationProvider {
if oc.providerOverride != nil {
if p, ok := oc.providerOverride[key]; ok {
return p
}
}
return GetObservationProvider(key)
}
// Get collects observation data for the given key (lazily) and unmarshals it into dest.
// Thread-safe: concurrent calls for the same key will only trigger one collection.
func (oc *ObservationContext) Get(ctx context.Context, key happydns.ObservationKey, dest any) error {
// Fast path: check cache under read lock.
oc.mu.RLock()
if raw, ok := oc.cache[key]; ok {
oc.mu.RUnlock()
return json.Unmarshal(raw, dest)
}
if err, ok := oc.errors[key]; ok {
oc.mu.RUnlock()
return err
}
oc.mu.RUnlock()
// Slow path: acquire write lock and collect.
oc.mu.Lock()
defer oc.mu.Unlock()
// Double-check after acquiring write lock.
if raw, ok := oc.cache[key]; ok {
return json.Unmarshal(raw, dest)
}
if err, ok := oc.errors[key]; ok {
return err
}
// Try DB cache before collecting fresh data.
if oc.cacheLookup != nil && oc.freshness > 0 {
if raw, collectedAt, err := oc.cacheLookup(oc.target, key); err == nil {
if time.Since(collectedAt) < oc.freshness {
oc.cache[key] = raw
return json.Unmarshal(raw, dest)
}
}
}
provider := oc.getProvider(key)
if provider == nil {
err := fmt.Errorf("no observation provider registered for key %q", key)
oc.errors[key] = err
return err
}
val, err := provider.Collect(ctx, oc.opts)
if err != nil {
oc.errors[key] = err
return err
}
raw, err := json.Marshal(val)
if err != nil {
err = fmt.Errorf("observation %q: marshal failed: %w", key, err)
oc.errors[key] = err
return err
}
oc.cache[key] = json.RawMessage(raw)
return json.Unmarshal(raw, dest)
}
// Data returns all cached observation data as pre-serialized JSON.
func (oc *ObservationContext) Data() map[happydns.ObservationKey]json.RawMessage {
oc.mu.RLock()
defer oc.mu.RUnlock()
data := make(map[happydns.ObservationKey]json.RawMessage, len(oc.cache))
for k, v := range oc.cache {
data[k] = v
}
return data
}
// HasHTMLReporter returns true if any registered observation provider implements CheckerHTMLReporter.
func HasHTMLReporter() bool {
for _, p := range sdk.GetObservationProviders() {
if _, ok := p.(happydns.CheckerHTMLReporter); ok {
return true
}
}
return false
}
// GetHTMLReport renders an HTML report for the given observation key and raw JSON data.
// Returns (html, true, nil) if the provider supports HTML reports, or ("", false, nil) if not.
func GetHTMLReport(key happydns.ObservationKey, raw json.RawMessage) (string, bool, error) {
provider := GetObservationProvider(key)
if provider == nil {
return "", false, fmt.Errorf("no observation provider registered for key %q", key)
}
hr, ok := provider.(happydns.CheckerHTMLReporter)
if !ok {
return "", false, nil
}
html, err := hr.GetHTMLReport(raw)
return html, true, err
}
// HasMetricsReporter returns true if any registered observation provider implements CheckerMetricsReporter.
func HasMetricsReporter() bool {
for _, p := range sdk.GetObservationProviders() {
if _, ok := p.(happydns.CheckerMetricsReporter); ok {
return true
}
}
return false
}
// GetMetrics extracts metrics for the given observation key and raw JSON data.
// Returns (metrics, true, nil) if the provider supports metrics, or (nil, false, nil) if not.
func GetMetrics(key happydns.ObservationKey, raw json.RawMessage, collectedAt time.Time) ([]happydns.CheckMetric, bool, error) {
provider := GetObservationProvider(key)
if provider == nil {
return nil, false, fmt.Errorf("no observation provider registered for key %q", key)
}
mr, ok := provider.(happydns.CheckerMetricsReporter)
if !ok {
return nil, false, nil
}
metrics, err := mr.ExtractMetrics(raw, collectedAt)
return metrics, true, err
}
// GetAllMetrics extracts metrics from all observation keys in a snapshot.
func GetAllMetrics(snap *happydns.ObservationSnapshot) ([]happydns.CheckMetric, error) {
var allMetrics []happydns.CheckMetric
for key, raw := range snap.Data {
metrics, supported, err := GetMetrics(key, raw, snap.CollectedAt)
if err != nil || !supported {
continue
}
allMetrics = append(allMetrics, metrics...)
}
return allMetrics, nil
}

View file

@ -0,0 +1,55 @@
// 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 checker
import (
sdk "git.happydns.org/checker-sdk-go/checker"
"git.happydns.org/happyDomain/model"
)
// The checker definition registry lives in the Apache-2.0 licensed
// checker-sdk-go module, so external plugins can register themselves
// without depending on AGPL code. These wrappers preserve the existing
// happyDomain call sites.
// RegisterChecker registers a checker definition globally.
func RegisterChecker(c *happydns.CheckerDefinition) {
sdk.RegisterChecker(c)
}
// RegisterExternalizableChecker registers a checker that supports being
// delegated to a remote HTTP endpoint. It appends an "endpoint" AdminOpt
// so the administrator can optionally configure a remote URL.
// When the endpoint is left empty, the checker runs locally as usual.
func RegisterExternalizableChecker(c *happydns.CheckerDefinition) {
sdk.RegisterExternalizableChecker(c)
}
// GetCheckers returns all registered checker definitions.
func GetCheckers() map[string]*happydns.CheckerDefinition {
return sdk.GetCheckers()
}
// FindChecker returns the checker definition with the given ID, or nil.
func FindChecker(id string) *happydns.CheckerDefinition {
return sdk.FindChecker(id)
}

View file

@ -24,6 +24,7 @@ package config // import "git.happydns.org/happyDomain/config"
import (
"flag"
"fmt"
"runtime"
"git.happydns.org/happyDomain/internal/storage"
"git.happydns.org/happyDomain/model"
@ -45,6 +46,7 @@ func declareFlags(o *happydns.Options) {
flag.Var(&JWTSecretKey{&o.JWTSecretKey}, "jwt-secret-key", "Secret key used to verify JWT authentication tokens (a random secret is used if undefined)")
flag.Var(&URL{&o.ExternalAuth}, "external-auth", "Base URL to use for login and registration (use embedded forms if left empty)")
flag.BoolVar(&o.OptOutInsights, "opt-out-insights", false, "Disable the anonymous usage statistics report. If you care about this project and don't participate in discussions, don't opt-out.")
flag.IntVar(&o.CheckerMaxConcurrency, "checker-max-concurrency", runtime.NumCPU(), "Maximum number of checker jobs that can run simultaneously")
flag.Var(&URL{&o.ListmonkURL}, "newsletter-server-url", "Base URL of the listmonk newsletter server")
flag.IntVar(&o.ListmonkID, "newsletter-id", 1, "Listmonk identifier of the list receiving the new user")
@ -60,6 +62,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(&stringSlice{&o.PluginsDirectories}, "plugins-directory", "Path to a directory containing checker plugins (.so files); may be repeated")
// Others flags are declared in some other files likes sources, storages, ... when they need specials configurations
}

View file

@ -25,8 +25,27 @@ import (
"encoding/base64"
"net/mail"
"net/url"
"strings"
)
// stringSlice is a flag.Value that accumulates string values across repeated
// invocations of the same flag (e.g. -plugins-directory a -plugins-directory b).
type stringSlice struct {
Values *[]string
}
func (s *stringSlice) String() string {
if s.Values == nil {
return ""
}
return strings.Join(*s.Values, ",")
}
func (s *stringSlice) Set(value string) error {
*s.Values = append(*s.Values, value)
return nil
}
type JWTSecretKey struct {
Secret *[]byte
}

View file

@ -22,7 +22,9 @@
package forms // import "git.happydns.org/happyDomain/forms"
import (
"fmt"
"reflect"
"slices"
"strings"
"git.happydns.org/happyDomain/model"
@ -78,6 +80,53 @@ func GenField(field reflect.StructField) (f *happydns.Field) {
return
}
// ValidateStructValues validates the field values of a struct against the
// constraints declared in its happydomain struct tags (choices, required).
// Since the struct is already typed, basic type checking is handled by the
// JSON decoder; this function validates higher-level constraints.
func ValidateStructValues(data any) error {
if data == nil {
return nil
}
v := reflect.Indirect(reflect.ValueOf(data))
t := v.Type()
for i := 0; i < t.NumField(); i++ {
sf := t.Field(i)
if sf.Anonymous {
if err := ValidateStructValues(v.Field(i).Interface()); err != nil {
return err
}
continue
}
field := GenField(sf)
fv := v.Field(i)
if field.Required && fv.IsZero() {
label := field.Label
if label == "" {
label = field.Id
}
return fmt.Errorf("field %q is required", label)
}
if len(field.Choices) > 0 && fv.Kind() == reflect.String {
s := fv.String()
if s != "" && !slices.Contains(field.Choices, s) {
label := field.Label
if label == "" {
label = field.Id
}
return fmt.Errorf("field %q: value %q is not a valid choice (valid: %v)", label, s, field.Choices)
}
}
}
return nil
}
// GenStructFields generates corresponding SourceFields of the given Source.
func GenStructFields(data any) (fields []*happydns.Field) {
if data != nil {

View file

@ -33,6 +33,9 @@ func DoSettingState(fu happydns.FormUsecase, state *happydns.FormState, data any
}
if state.State == 1 {
if verr := ValidateStructValues(data); verr != nil {
return nil, nil, verr
}
err = happydns.DoneForm
} else {
form = defaultForm(data)

View file

@ -1,5 +1,5 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2024 happyDomain
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
@ -19,40 +19,50 @@
// 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 providers // import "git.happydns.org/happyDomain/providers"
package provider
import (
"fmt"
"log"
"reflect"
"slices"
"git.happydns.org/happyDomain/model"
)
// providers stores all existing Provider in happyDNS.
var providersList map[string]happydns.ProviderCreator = map[string]happydns.ProviderCreator{}
// providerRegistry stores all existing Provider in happyDNS.
var providerRegistry = map[string]happydns.ProviderCreator{}
// RegisterProvider declares the existence of the given Provider.
// RegisterProvider registers a provider definition globally.
func RegisterProvider(creator happydns.ProviderCreatorFunc, infos happydns.ProviderInfos) {
provider := creator()
baseType := reflect.Indirect(reflect.ValueOf(provider)).Type()
name := baseType.Name()
log.Println("Registering new provider:", name)
providersList[name] = happydns.ProviderCreator{
providerRegistry[name] = happydns.ProviderCreator{
Creator: creator,
Infos: infos,
}
}
// GetProviders retrieves the list of all existing Providers.
func GetProviders() *map[string]happydns.ProviderCreator {
return &providersList
// GetProviders returns all registered provider definitions.
func GetProviders() map[string]happydns.ProviderCreator {
return providerRegistry
}
// ProviderHasCapability checks if the registered provider type has the given capability.
func ProviderHasCapability(provider *happydns.Provider, capability string) bool {
creator, ok := providerRegistry[provider.Type]
if !ok {
return false
}
return slices.Contains(creator.Infos.Capabilities, capability)
}
// FindProvider returns the Provider corresponding to the given name, or an error if it doesn't exist.
func FindProvider(name string) (happydns.ProviderBody, error) {
src, ok := providersList[name]
src, ok := providerRegistry[name]
if !ok {
return nil, fmt.Errorf("Unable to find corresponding provider for `%s`.", name)
}

View file

@ -1,5 +1,5 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2024 happyDomain
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
@ -19,7 +19,7 @@
// 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 svcs
package service
import (
"errors"
@ -240,6 +240,10 @@ func getMostUsedTTL(zone []happydns.Record) uint32 {
return max
}
// OrphanCreator is a function that creates an Orphan service wrapping a DNS record.
// It is set by the services package during init() to avoid a circular import.
var OrphanCreator func(record happydns.Record) happydns.ServiceBody
// AnalyzeZone converts raw DNS records into higher-level services by running
// each registered ServiceAnalyzer in priority order. Records not claimed by
// any analyzer are wrapped as Orphan services.
@ -286,21 +290,23 @@ func AnalyzeZone(origin string, records []happydns.Record) (svcs map[happydns.Su
}
// Consider unclaimed records as Orphan
for i, record := range a.zone {
if a.claimed[i] {
continue
}
// Skip DNSSEC records
if helpers.IsDNSSECType(record.Header().Rrtype) {
continue
}
if record.Header().Name == "__dnssec."+origin && record.Header().Rrtype == dns.TypeTXT {
continue
}
if OrphanCreator != nil {
for i, record := range a.zone {
if a.claimed[i] {
continue
}
// Skip DNSSEC records
if helpers.IsDNSSECType(record.Header().Rrtype) {
continue
}
if record.Header().Name == "__dnssec."+origin && record.Header().Rrtype == dns.TypeTXT {
continue
}
domain := record.Header().Name
domain := record.Header().Name
a.addService(record, domain, &Orphan{helpers.RRRelativeSubdomain(record, a.GetOrigin(), domain)})
a.addService(record, domain, OrphanCreator(helpers.RRRelativeSubdomain(record, a.GetOrigin(), domain)))
}
}
svcs = a.services

View file

@ -1,5 +1,5 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2024 happyDomain
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
@ -19,7 +19,7 @@
// 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 svcs
package service
import (
"fmt"
@ -61,9 +61,6 @@ func RegisterService(creator happydns.ServiceCreator, analyzer ServiceAnalyzer,
// Override given parameters by true one
infos.Type = name
if _, ok := Icons[name]; ok {
infos.Icon = "/api/service_specs/" + name + "/icon.png"
}
svc := &Svc{
creator,

View file

@ -69,6 +69,11 @@ func (it *KVIterator) Valid() bool {
return it.index >= 0 && it.index < len(it.keys)
}
// Err returns nil as in-memory iterators never error.
func (it *KVIterator) Err() error {
return nil
}
// Release releases the iterator resources.
func (it *KVIterator) Release() {
// No resources to release for in-memory iterator

View file

@ -64,6 +64,7 @@ type Iterator interface {
Valid() bool
Key() string
Value() any
Err() error
}
type KVStorage interface {

View file

@ -60,6 +60,10 @@ func (s *KVStorage) GetAuthUserByEmail(email string) (*happydns.UserAuth, error)
}
}
if err := users.Err(); err != nil {
return nil, err
}
return nil, fmt.Errorf("Unable to find user with email address '%s'.", email)
}
@ -77,6 +81,10 @@ func (s *KVStorage) AuthUserExists(email string) (bool, error) {
}
}
if err := users.Err(); err != nil {
return false, err
}
return false, nil
}
@ -112,5 +120,5 @@ func (s *KVStorage) ClearAuthUsers() error {
}
}
return nil
return iter.Err()
}

View file

@ -79,6 +79,10 @@ func (s *KVStorage) ListDomainLogs(domain *happydns.Domain) (logs []*happydns.Do
logs = append(logs, &z)
}
if err = iter.Err(); err != nil {
return
}
return
}

View file

@ -51,6 +51,10 @@ func (s *KVStorage) ListDomains(u *happydns.User) (domains []*happydns.Domain, e
}
}
if err = iter.Err(); err != nil {
return
}
return
}
@ -124,5 +128,5 @@ func (s *KVStorage) ClearDomains() error {
}
}
return nil
return iter.Err()
}

View file

@ -111,7 +111,10 @@ func (it *KVIterator[T]) Key() string {
// Err returns the first error encountered during iteration, if any.
func (it *KVIterator[T]) Err() error {
return it.err
if it.err != nil {
return it.err
}
return it.iter.Err()
}
// Close releases resources held by the underlying LevelDB iterator.

View file

@ -59,6 +59,10 @@ func (s *KVStorage) ListProviders(u *happydns.User) (srcs happydns.ProviderMessa
srcs = append(srcs, srcMsg)
}
if err = iter.Err(); err != nil {
return
}
return
}
@ -108,5 +112,5 @@ func (s *KVStorage) ClearProviders() error {
}
}
return nil
return iter.Err()
}

View file

@ -71,6 +71,10 @@ func (s *KVStorage) ListAuthUserSessions(user *happydns.UserAuth) (sessions []*h
}
}
if err = iter.Err(); err != nil {
return
}
return
}
@ -90,6 +94,10 @@ func (s *KVStorage) ListUserSessions(userid happydns.Identifier) (sessions []*ha
}
}
if err = iter.Err(); err != nil {
return
}
return
}
@ -112,5 +120,5 @@ func (s *KVStorage) ClearSessions() error {
}
}
return nil
return iter.Err()
}

View file

@ -122,6 +122,10 @@ func migrateFrom0_sourcesProvider(s *KVStorage) (err error) {
}
}
if err = iter.Err(); err != nil {
return
}
return
}
@ -147,5 +151,9 @@ func migrateFrom0_reparentDomains(s *KVStorage) (err error) {
}
}
if err = iter.Err(); err != nil {
return
}
return
}

View file

@ -132,6 +132,10 @@ func migrateFrom1_users_tree(s *KVStorage) (err error) {
}
}
if err = iter.Err(); err != nil {
return
}
return
}
@ -162,6 +166,10 @@ func migrateFrom1_domains(s *KVStorage, oldUserId int64, newUserId string) (err
}
}
if err = iter.Err(); err != nil {
return
}
return
}
@ -192,6 +200,10 @@ func migrateFrom1_provider(s *KVStorage, oldUserId int64, newUserId string) (err
}
}
if err = iter.Err(); err != nil {
return
}
return
}
@ -222,5 +234,9 @@ func migrateFrom1_zone(s *KVStorage, oldUserId int64, newUserId string) (err err
}
}
if err = iter.Err(); err != nil {
return
}
return
}

View file

@ -102,6 +102,10 @@ func migrateFrom2_users_tree(s *KVStorage) (err error) {
}
}
if err = iter.Err(); err != nil {
return
}
return
}
@ -201,6 +205,10 @@ func migrateFrom2_session(s *KVStorage, oldUserId happydns.HexaString, newUserId
}
}
if err = kvIter.Err(); err != nil {
return
}
return
}
@ -267,6 +275,10 @@ func migrateFrom2_provider(s *KVStorage, oldUserId happydns.HexaString, newUserI
}
}
if err = kvIter.Err(); err != nil {
return
}
return
}
@ -354,6 +366,10 @@ func migrateFrom2_domains(s *KVStorage, oldUserId happydns.HexaString, newUserId
}
}
if err = kvIter.Err(); err != nil {
return
}
return
}

View file

@ -63,5 +63,9 @@ func migrateFrom3_records(s *KVStorage) error {
}
}
if err := iter.Err(); err != nil {
return err
}
return nil
}

View file

@ -1046,6 +1046,10 @@ func migrateFrom7(s *KVStorage) error {
}
}
if err := zones.Err(); err != nil {
return err
}
zones, err = s.ListAllZones()
if err != nil {
return err
@ -1082,5 +1086,9 @@ func migrateFrom7(s *KVStorage) error {
}
}
if err := zones.Err(); err != nil {
return err
}
return nil
}

View file

@ -92,5 +92,9 @@ func migrateFrom8(s *KVStorage) error {
}
}
if err := zones.Err(); err != nil {
return err
}
return nil
}

View file

@ -44,5 +44,9 @@ func migrateFrom9(s *KVStorage) (err error) {
}
}
if err := sessions.Err(); err != nil {
return err
}
return nil
}

View file

@ -60,6 +60,10 @@ func (s *KVStorage) GetUserByEmail(email string) (*happydns.User, error) {
}
}
if err := users.Err(); err != nil {
return nil, err
}
return nil, happydns.ErrUserNotFound
}
@ -76,6 +80,7 @@ func (s *KVStorage) UserExists(email string) bool {
}
}
// Note: iterator errors are swallowed here since the function returns bool only
return false
}
@ -114,5 +119,5 @@ func (s *KVStorage) ClearUsers() error {
}
}
return nil
return iter.Err()
}

View file

@ -92,5 +92,5 @@ func (s *KVStorage) ClearZones() error {
}
}
return nil
return iter.Err()
}

View file

@ -51,6 +51,10 @@ func (it *LevelDBIterator) Valid() bool {
return it.iter.Valid()
}
func (it *LevelDBIterator) Err() error {
return it.iter.Error()
}
func (it *LevelDBIterator) Release() {
it.iter.Release()
}

View file

@ -24,6 +24,8 @@ package database
import (
"encoding/json"
"fmt"
"strings"
"sync"
"github.com/oracle/nosql-go-sdk/nosqldb"
"github.com/oracle/nosql-go-sdk/nosqldb/auth/iam"
@ -36,9 +38,11 @@ import (
)
type NoSQLStorage struct {
client *nosqldb.Client
config *nosqldb.Config
table string
client *nosqldb.Client
config *nosqldb.Config
table string
prepareSearchOnce sync.Once
searchStmt nosqldb.PreparedStatement
}
// NewOCINoSQLStorage establishes the connection to the database
@ -59,6 +63,7 @@ func NewOCINoSQLStorage(cfg *OCINoSQLConfig) (s *NoSQLStorage, err error) {
Mode: "cloud",
Region: common.Region(cfg.Region),
AuthorizationProvider: authProvider,
RateLimitingEnabled: true,
}
// Create client
@ -187,10 +192,50 @@ func (n *NoSQLStorage) Delete(key string) error {
return nil
}
func (n *NoSQLStorage) Search(prefix string) storage.Iterator {
query := fmt.Sprintf("SELECT * FROM %s WHERE regex_like(key, '%s.*')", n.table, prefix)
return NewIteratorFromRequest(n, &nosqldb.QueryRequest{
Statement: query,
})
// escapeRegexLiteral escapes regex-special characters with a single backslash,
// suitable for bind variables where the value is passed directly to the regex engine.
func escapeRegexLiteral(s string) string {
for _, ch := range []string{`\`, `|`, `.`, `^`, `$`, `*`, `+`, `?`, `(`, `)`, `[`, `]`, `{`, `}`} {
s = strings.ReplaceAll(s, ch, `\`+ch)
}
return s
}
// escapeRegexForSQL escapes regex-special characters with double backslashes,
// suitable for embedding in SQL string literals (the SQL parser consumes one level).
func escapeRegexForSQL(s string) string {
for _, ch := range []string{`\`, `|`, `.`, `^`, `$`, `*`, `+`, `?`, `(`, `)`, `[`, `]`, `{`, `}`} {
s = strings.ReplaceAll(s, ch, `\\`+ch)
}
return s
}
func (n *NoSQLStorage) prepareSearch() error {
var err error
n.prepareSearchOnce.Do(func() {
stmt := fmt.Sprintf(
"DECLARE $pattern STRING; SELECT * FROM %s WHERE regex_like(key, $pattern)",
n.table,
)
res, e := n.client.Prepare(&nosqldb.PrepareRequest{Statement: stmt})
if e != nil {
err = e
return
}
n.searchStmt = res.PreparedStatement
})
return err
}
func (n *NoSQLStorage) Search(prefix string) storage.Iterator {
if err := n.prepareSearch(); err != nil {
// Fall back to unprepared query (SQL string literal needs double-escaped regex)
query := fmt.Sprintf("SELECT * FROM %s WHERE regex_like(key, '%s.*')", n.table, strings.ReplaceAll(escapeRegexForSQL(prefix), "'", "''"))
return NewIteratorFromRequest(n, &nosqldb.QueryRequest{Statement: query})
}
// Struct copy — each Search gets its own bindVariables map
stmt := n.searchStmt
stmt.SetVariable("$pattern", escapeRegexLiteral(prefix)+".*")
return NewIteratorFromRequest(n, &nosqldb.QueryRequest{PreparedStatement: &stmt})
}

View file

@ -23,19 +23,20 @@ package database
import (
"log"
"time"
"github.com/oracle/nosql-go-sdk/nosqldb"
"github.com/oracle/nosql-go-sdk/nosqldb/nosqlerr"
"github.com/oracle/nosql-go-sdk/nosqldb/types"
)
type Iterator struct {
firstPassed bool
n *NoSQLStorage
req *nosqldb.QueryRequest
res *nosqldb.QueryResult
results []*types.MapValue
cur_result int
err error
n *NoSQLStorage
req *nosqldb.QueryRequest
results []*types.MapValue
cur_result int
started bool
err error
}
func NewIteratorFromRequest(n *NoSQLStorage, req *nosqldb.QueryRequest) *Iterator {
@ -45,41 +46,57 @@ func NewIteratorFromRequest(n *NoSQLStorage, req *nosqldb.QueryRequest) *Iterato
}
}
func (i *Iterator) Release() {}
func (i *Iterator) Release() {
i.req.Close()
}
func (i *Iterator) Next() bool {
i.err = nil
if i.res == nil {
if i.firstPassed && i.req.IsDone() {
// Advance within current batch.
if i.results != nil {
i.cur_result++
if i.cur_result < len(i.results) {
return true
}
}
// Fetch new batches until we get results or the query is done.
// The SDK may return empty batches (e.g. during auto-preparation
// or when the read limit is hit), so we must loop.
// Note: IsDone() checks continuationKey == nil, which is also true
// for a fresh QueryRequest that has never been executed. We skip the
// check only before the first Query() call.
for {
if i.started && i.req.IsDone() {
return false
}
i.firstPassed = true
i.res, i.err = i.n.client.Query(i.req)
res, err := i.n.client.Query(i.req)
if err != nil {
// Retry with backoff on rate-limit errors
if nosqlerr.Is(err, nosqlerr.ReadLimitExceeded, nosqlerr.WriteLimitExceeded, nosqlerr.RequestTimeout) {
log.Println("rate limited in iterator, backing off:", err.Error())
time.Sleep(2 * time.Second)
continue
}
i.err = err
log.Println("error in iterator:", err.Error())
return false
}
i.started = true
i.results, i.err = res.GetResults()
if i.err != nil {
log.Println("error in iterator:", i.err.Error())
return false
}
i.results = nil
}
if i.results == nil {
i.results, i.err = i.res.GetResults()
if i.err != nil {
log.Println("error in iterator:", i.err.Error())
return false
if len(i.results) > 0 {
i.cur_result = 0
return true
}
i.cur_result = 0
} else {
i.cur_result += 1
}
if i.cur_result+1 >= len(i.results) {
i.res = nil
}
return i.cur_result < len(i.results)
}
func (i *Iterator) Valid() bool {
@ -98,3 +115,7 @@ func (i *Iterator) Value() any {
value, _ := i.results[i.cur_result].Get("value")
return value
}
func (i *Iterator) Err() error {
return i.err
}

View file

@ -89,3 +89,8 @@ func (it *PostgreSQLIterator) Key() string {
func (it *PostgreSQLIterator) Value() any {
return it.value
}
// Err returns the last error encountered during iteration
func (it *PostgreSQLIterator) Err() error {
return it.err
}

View file

@ -22,6 +22,7 @@
package domain
import (
"context"
"errors"
"fmt"
@ -32,12 +33,12 @@ import (
// ProviderGetter is an interface for getting providers.
type ProviderGetter interface {
GetUserProvider(user *happydns.User, providerID happydns.Identifier) (*happydns.Provider, error)
GetUserProvider(ctx context.Context, user *happydns.User, providerID happydns.Identifier) (*happydns.Provider, error)
}
// DomainExistenceTester is an interface for testing domain existence.
type DomainExistenceTester interface {
TestDomainExistence(provider *happydns.Provider, name string) error
TestDomainExistence(ctx context.Context, provider *happydns.Provider, name string) error
}
type Service struct {
@ -65,18 +66,18 @@ func NewService(
}
// CreateDomain creates a new domain for the given user.
func (s *Service) CreateDomain(user *happydns.User, uz *happydns.Domain) error {
func (s *Service) CreateDomain(ctx context.Context, user *happydns.User, uz *happydns.Domain) error {
uz, err := happydns.NewDomain(user, uz.DomainName, uz.ProviderId)
if err != nil {
return err
}
provider, err := s.providerService.GetUserProvider(user, uz.ProviderId)
provider, err := s.providerService.GetUserProvider(ctx, user, uz.ProviderId)
if err != nil {
return happydns.ValidationError{Msg: fmt.Sprintf("unable to find the provider.")}
}
if err = s.domainExistence.TestDomainExistence(provider, uz.DomainName); err != nil {
if err = s.domainExistence.TestDomainExistence(ctx, provider, uz.DomainName); err != nil {
return happydns.NotFoundError{Msg: err.Error()}
}

View file

@ -22,9 +22,11 @@
package domain_test
import (
"context"
"fmt"
"testing"
providerReg "git.happydns.org/happyDomain/internal/provider"
"git.happydns.org/happyDomain/internal/storage"
"git.happydns.org/happyDomain/internal/storage/inmemory"
kv "git.happydns.org/happyDomain/internal/storage/kvtpl"
@ -32,14 +34,15 @@ import (
providerUC "git.happydns.org/happyDomain/internal/usecase/provider"
zoneUC "git.happydns.org/happyDomain/internal/usecase/zone"
"git.happydns.org/happyDomain/model"
"git.happydns.org/happyDomain/providers"
)
var ctx = context.Background()
// Mock implementations for testing
func init() {
// Register the mock provider
providers.RegisterProvider(func() happydns.ProviderBody {
providerReg.RegisterProvider(func() happydns.ProviderBody {
return &mockProviderBody{}
}, happydns.ProviderInfos{
Name: "Mock Provider",
@ -159,7 +162,7 @@ func Test_CreateDomain(t *testing.T) {
ProviderId: providerId,
}
err := service.CreateDomain(user, domainToCreate)
err := service.CreateDomain(ctx, user, domainToCreate)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@ -205,7 +208,7 @@ func Test_CreateDomain_InvalidProvider(t *testing.T) {
ProviderId: invalidProviderId,
}
err := service.CreateDomain(user, domainToCreate)
err := service.CreateDomain(ctx, user, domainToCreate)
if err == nil {
t.Error("expected error when creating domain with invalid provider")
}
@ -224,7 +227,7 @@ func Test_GetUserDomain(t *testing.T) {
DomainName: "example.com",
ProviderId: providerId,
}
err := service.CreateDomain(user, domainToCreate)
err := service.CreateDomain(ctx, user, domainToCreate)
if err != nil {
t.Fatalf("failed to create domain: %v", err)
}
@ -265,7 +268,7 @@ func Test_GetUserDomain_WrongUser(t *testing.T) {
DomainName: "user1-domain.com",
ProviderId: providerId,
}
err := service.CreateDomain(user1, domainToCreate)
err := service.CreateDomain(ctx, user1, domainToCreate)
if err != nil {
t.Fatalf("failed to create domain: %v", err)
}
@ -317,7 +320,7 @@ func Test_GetUserDomainByFQDN(t *testing.T) {
DomainName: "example.com.",
ProviderId: providerId,
}
err := service.CreateDomain(user, domainToCreate)
err := service.CreateDomain(ctx, user, domainToCreate)
if err != nil {
t.Fatalf("failed to create domain: %v", err)
}
@ -352,7 +355,7 @@ func Test_ListUserDomains(t *testing.T) {
DomainName: name,
ProviderId: providerId,
}
err := service.CreateDomain(user, domainToCreate)
err := service.CreateDomain(ctx, user, domainToCreate)
if err != nil {
t.Fatalf("failed to create domain %s: %v", name, err)
}
@ -385,7 +388,7 @@ func Test_ListUserDomains_MultipleUsers(t *testing.T) {
DomainName: fmt.Sprintf("user1-domain%d.com", i),
ProviderId: providerId1,
}
err := service.CreateDomain(user1, domainToCreate)
err := service.CreateDomain(ctx, user1, domainToCreate)
if err != nil {
t.Fatalf("failed to create domain: %v", err)
}
@ -396,7 +399,7 @@ func Test_ListUserDomains_MultipleUsers(t *testing.T) {
DomainName: "user2-domain.com",
ProviderId: providerId2,
}
err := service.CreateDomain(user2, domainToCreate)
err := service.CreateDomain(ctx, user2, domainToCreate)
if err != nil {
t.Fatalf("failed to create domain: %v", err)
}
@ -451,7 +454,7 @@ func Test_UpdateDomain(t *testing.T) {
DomainName: "example.com",
ProviderId: providerId,
}
err := service.CreateDomain(user, domainToCreate)
err := service.CreateDomain(ctx, user, domainToCreate)
if err != nil {
t.Fatalf("failed to create domain: %v", err)
}
@ -503,7 +506,7 @@ func Test_UpdateDomain_PreventIdChange(t *testing.T) {
DomainName: "example.com",
ProviderId: providerId,
}
err := service.CreateDomain(user, domainToCreate)
err := service.CreateDomain(ctx, user, domainToCreate)
if err != nil {
t.Fatalf("failed to create domain: %v", err)
}
@ -546,7 +549,7 @@ func Test_UpdateDomain_WrongUser(t *testing.T) {
DomainName: "user1-domain.com",
ProviderId: providerId,
}
err := service.CreateDomain(user1, domainToCreate)
err := service.CreateDomain(ctx, user1, domainToCreate)
if err != nil {
t.Fatalf("failed to create domain: %v", err)
}
@ -580,7 +583,7 @@ func Test_DeleteDomain(t *testing.T) {
DomainName: "example.com",
ProviderId: providerId,
}
err := service.CreateDomain(user, domainToCreate)
err := service.CreateDomain(ctx, user, domainToCreate)
if err != nil {
t.Fatalf("failed to create domain: %v", err)
}
@ -621,7 +624,7 @@ func Test_UpdateDomain_Alias(t *testing.T) {
DomainName: "example.com",
ProviderId: providerId,
}
err := service.CreateDomain(user, domainToCreate)
err := service.CreateDomain(ctx, user, domainToCreate)
if err != nil {
t.Fatalf("failed to create domain: %v", err)
}

View file

@ -89,6 +89,9 @@ func Collect(
for authusers.Next() {
data.Database.NbAuthUsers++
}
if err := authusers.Err(); err != nil {
return nil, err
}
}
users, err := store.ListAllUsers()
@ -111,8 +114,8 @@ func Collect(
if user.Settings.Newsletter {
data.UserSettings.Newsletter++
}
data.UserSettings.FieldHints[user.Settings.FieldHint]++
data.UserSettings.ZoneView[user.Settings.ZoneView]++
data.UserSettings.FieldHints[int(user.Settings.FieldHint)]++
data.UserSettings.ZoneView[int(user.Settings.ZoneView)]++
if providers, err := store.ListProviders(user); err == nil {
for _, provider := range providers {
@ -129,5 +132,9 @@ func Collect(
}
}
if err := users.Err(); err != nil {
return nil, err
}
return &data, nil
}

View file

@ -41,7 +41,7 @@ type DomainUpdater interface {
// ProviderGetter is an interface for getting providers.
type ProviderGetter interface {
GetUserProvider(user *happydns.User, providerID happydns.Identifier) (*happydns.Provider, error)
GetUserProvider(ctx context.Context, user *happydns.User, providerID happydns.Identifier) (*happydns.Provider, error)
}
// ZoneRetriever is an interface for retrieving zones from providers.
@ -87,7 +87,7 @@ func NewOrchestrator(
zoneCorrectionLister := NewZoneCorrectionListerUsecase(providerService, listRecords, zoneCorrectorService, zoneRetrieverService)
return &Orchestrator{
RemoteZoneImporter: NewRemoteZoneImporterUsecase(appendDomainLog, providerService, zoneImporter, zoneRetrieverService),
ZoneCorrectionApplier: NewZoneCorrectionApplierUsecase(appendDomainLog, domainUpdater, zoneCorrectionLister, zoneCreator, zoneGetter, zoneUpdater),
ZoneCorrectionApplier: NewZoneCorrectionApplierUsecase(appendDomainLog, domainUpdater, zoneCorrectionLister, zoneCreator, zoneGetter, zoneRetrieverService, zoneUpdater),
ZoneImporter: zoneImporter,
}
}

View file

@ -0,0 +1,130 @@
// 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 orchestrator
import (
"time"
"github.com/miekg/dns"
svc "git.happydns.org/happyDomain/internal/service"
zoneUC "git.happydns.org/happyDomain/internal/usecase/zone"
"git.happydns.org/happyDomain/model"
)
// SetPropagationTimes stamps each service in newServices with a PropagatedAt
// time based on whether the service changed compared to the provider state.
// It reuses the same matching technique as ReassociateMetadata (subdomain +
// type + ServiceRDataHash).
//
// For changed/updated services: PropagatedAt = publishTime + old service TTL.
// For new services (additions): PropagatedAt = publishTime + SOA minimum TTL
// (negative cache duration), falling back to defaultTTL.
func SetPropagationTimes(
newServices map[happydns.Subdomain][]*happydns.Service,
providerRecords []happydns.Record,
origin string,
defaultTTL uint32,
publishTime time.Time,
) {
// Find SOA minimum TTL for negative cache duration (used for additions).
negativeCacheTTL := defaultTTL
for _, rr := range providerRecords {
if rr.Header().Rrtype == dns.TypeSOA {
if soa, ok := rr.(*dns.SOA); ok {
negativeCacheTTL = soa.Minttl
}
break
}
}
// Analyze provider records into old services for comparison.
oldServices, oldDefaultTTL, err := svc.AnalyzeZone(origin, providerRecords)
if err != nil {
return
}
for dn, newSvcs := range newServices {
oldSvcs := oldServices[dn]
// Group old services by type.
oldByType := map[string][]*happydns.Service{}
for _, s := range oldSvcs {
oldByType[s.Type] = append(oldByType[s.Type], s)
}
for _, newSvc := range newSvcs {
candidates := oldByType[newSvc.Type]
if len(candidates) == 0 {
// New service (addition): use SOA negative cache TTL.
propagatedAt := publishTime.Add(time.Duration(negativeCacheTTL) * time.Second)
newSvc.PropagatedAt = &propagatedAt
continue
}
newHash := zoneUC.ServiceRDataHash(newSvc, origin, defaultTTL)
if len(candidates) == 1 {
oldSvc := candidates[0]
oldHash := zoneUC.ServiceRDataHash(oldSvc, origin, oldDefaultTTL)
if newHash != oldHash {
// Service changed: use old service TTL.
oldTTL := oldDefaultTTL
if oldSvc.Ttl != 0 {
oldTTL = oldSvc.Ttl
}
propagatedAt := publishTime.Add(time.Duration(oldTTL) * time.Second)
newSvc.PropagatedAt = &propagatedAt
}
continue
}
// Multiple candidates: try to find exact RDATA match.
matched := false
for _, oldSvc := range candidates {
if zoneUC.ServiceRDataHash(oldSvc, origin, oldDefaultTTL) == newHash {
// Exact match: service unchanged, don't touch PropagatedAt.
matched = true
break
}
}
if !matched {
// No exact match: service was modified. Use the max TTL
// across all candidates of the same type as a conservative
// upper bound.
var maxOldTTL uint32
for _, oldSvc := range candidates {
ttl := oldDefaultTTL
if oldSvc.Ttl != 0 {
ttl = oldSvc.Ttl
}
if ttl > maxOldTTL {
maxOldTTL = ttl
}
}
propagatedAt := publishTime.Add(time.Duration(maxOldTTL) * time.Second)
newSvc.PropagatedAt = &propagatedAt
}
}
}
}

View file

@ -60,7 +60,7 @@ func NewRemoteZoneImporterUsecase(
// 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)
provider, err := uc.providerService.GetUserProvider(ctx, user, domain.ProviderId)
if err != nil {
return nil, err
}

View file

@ -28,10 +28,12 @@ import (
"time"
adapter "git.happydns.org/happyDomain/internal/adapters"
providerReg "git.happydns.org/happyDomain/internal/provider"
svc "git.happydns.org/happyDomain/internal/service"
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"
"git.happydns.org/happyDomain/services/abstract"
)
// ZoneCorrectionApplierUsecase applies a user-selected subset of zone
@ -43,6 +45,7 @@ type ZoneCorrectionApplierUsecase struct {
domainUpdater DomainUpdater
zoneCreator *zoneUC.CreateZoneUsecase
zoneGetter *zoneUC.GetZoneUsecase
zoneRetriever ZoneRetriever
zoneUpdater *zoneUC.UpdateZoneUsecase
clock func() time.Time
}
@ -56,6 +59,7 @@ func NewZoneCorrectionApplierUsecase(
lister *ZoneCorrectionListerUsecase,
zoneCreator *zoneUC.CreateZoneUsecase,
zoneGetter *zoneUC.GetZoneUsecase,
zoneRetriever ZoneRetriever,
zoneUpdater *zoneUC.UpdateZoneUsecase,
) *ZoneCorrectionApplierUsecase {
return &ZoneCorrectionApplierUsecase{
@ -64,6 +68,7 @@ func NewZoneCorrectionApplierUsecase(
domainUpdater: domainUpdater,
zoneCreator: zoneCreator,
zoneGetter: zoneGetter,
zoneRetriever: zoneRetriever,
zoneUpdater: zoneUpdater,
clock: time.Now,
}
@ -78,28 +83,28 @@ func (uc *ZoneCorrectionApplierUsecase) computeExecutableCorrections(
domain *happydns.Domain,
zone *happydns.Zone,
wantedCorrections []happydns.Identifier,
) (execCorrections []*happydns.Correction, targetRecords []happydns.Record, nbDiffs int, err error) {
) (execCorrections []*happydns.Correction, targetRecords []happydns.Record, providerRecords []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
return nil, 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)
provider, err := uc.providerService.GetUserProvider(ctx, user, domain.ProviderId)
if err != nil {
return nil, nil, nbDiffs, err
return nil, 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 nil, nil, nil, nbDiffs, fmt.Errorf("unable to compute executable corrections: %w", err)
}
return execCorrections, targetRecords, nbDiffs, nil
return execCorrections, targetRecords, providerRecords, nbDiffs, nil
}
// Prepare computes the executable corrections for the given selection without
@ -112,7 +117,7 @@ func (uc *ZoneCorrectionApplierUsecase) Prepare(
zone *happydns.Zone,
form *happydns.PrepareZoneForm,
) (*happydns.PrepareZoneResponse, error) {
execCorrections, _, nbDiffs, err := uc.computeExecutableCorrections(ctx, user, domain, zone, form.WantedCorrections)
execCorrections, _, _, nbDiffs, err := uc.computeExecutableCorrections(ctx, user, domain, zone, form.WantedCorrections)
if err != nil {
return nil, err
}
@ -142,7 +147,7 @@ func (uc *ZoneCorrectionApplierUsecase) Apply(
zone *happydns.Zone,
form *happydns.ApplyZoneForm,
) (*happydns.Zone, error) {
executableCorrections, targetRecords, _, err := uc.computeExecutableCorrections(ctx, user, domain, zone, form.WantedCorrections)
executableCorrections, targetRecords, providerRecords, _, err := uc.computeExecutableCorrections(ctx, user, domain, zone, form.WantedCorrections)
if err != nil {
return nil, err
}
@ -171,8 +176,22 @@ func (uc *ZoneCorrectionApplierUsecase) Apply(
log.Printf("unable to append domain log for %s: %s", domain.DomainName, logErr.Error())
}
// Step 5: Create a published snapshot zone from target records.
services, defaultTTL, err := svcs.AnalyzeZone(domain.DomainName, targetRecords)
// Step 4b: If provider manages SOA serial, re-fetch to get the actual published state.
publishedRecords := targetRecords
refetched := false
provider, provErr := uc.providerService.GetUserProvider(ctx, user, domain.ProviderId)
if provErr == nil && providerReg.ProviderHasCapability(provider, "manages-soa-serial") {
fetched, fetchErr := uc.zoneRetriever.RetrieveZone(ctx, provider, domain.DomainName)
if fetchErr != nil {
log.Printf("%s: unable to re-fetch zone after deploy, using target records: %s", domain.DomainName, fetchErr)
} else {
publishedRecords = fetched
refetched = true
}
}
// Step 5: Create a published snapshot zone from published records.
services, defaultTTL, err := svc.AnalyzeZone(domain.DomainName, publishedRecords)
if err != nil {
return nil, happydns.InternalError{
Err: fmt.Errorf("unable to analyze target zone: %w", err),
@ -196,6 +215,10 @@ func (uc *ZoneCorrectionApplierUsecase) Apply(
}
now := uc.clock()
// Compute propagation times for changed services on the snapshot.
SetPropagationTimes(services, providerRecords, domain.DomainName, defaultTTL, now)
snapshot := &happydns.Zone{
ZoneMeta: happydns.ZoneMeta{
IdAuthor: user.Id,
@ -204,7 +227,7 @@ func (uc *ZoneCorrectionApplierUsecase) Apply(
CommitMsg: &form.CommitMsg,
CommitDate: &now,
Published: &now,
ParentZone: &zone.ZoneMeta.Id,
ParentZone: zone.ParentZone,
},
Services: services,
}
@ -217,6 +240,28 @@ func (uc *ZoneCorrectionApplierUsecase) Apply(
}
}
// Update the parent zone of the WIP zone
zone.ParentZone = &snapshot.Id
// Step 5b: If we re-fetched, update the WIP zone's Origin SOA serial to match.
if refetched {
if newSerial, ok := extractOriginSOASerial(snapshot); ok {
if updateErr := uc.zoneUpdater.Update(zone.Id, func(z *happydns.Zone) {
if services, exists := z.Services[""]; exists {
for _, s := range services {
if s.Type == "abstract.Origin" {
if origin, ok := s.Service.(*abstract.Origin); ok && origin.SOA != nil {
origin.SOA.Serial = newSerial
}
}
}
}
}); updateErr != nil {
log.Printf("%s: unable to update WIP zone SOA serial: %s", domain.DomainName, updateErr)
}
}
}
// Step 6: Insert snapshot at ZoneHistory[1] (after WIP at position 0).
err = uc.domainUpdater.Update(domain.Id, user, func(domain *happydns.Domain) {
if len(domain.ZoneHistory) == 0 {
@ -236,5 +281,27 @@ func (uc *ZoneCorrectionApplierUsecase) Apply(
}
}
// Update propagation times on the WIP zone as well.
if updateErr := uc.zoneUpdater.Update(zone.Id, func(wipZone *happydns.Zone) {
SetPropagationTimes(wipZone.Services, providerRecords, domain.DomainName, wipZone.DefaultTTL, now)
}); updateErr != nil {
log.Printf("%s: unable to update WIP zone propagation times: %s", domain.DomainName, updateErr)
}
return snapshot, nil
}
// extractOriginSOASerial extracts the SOA serial from the Origin service
// at the zone apex, if present.
func extractOriginSOASerial(zone *happydns.Zone) (uint32, bool) {
if services, exists := zone.Services[""]; exists {
for _, s := range services {
if s.Type == "abstract.Origin" {
if origin, ok := s.Service.(*abstract.Origin); ok && origin.SOA != nil {
return origin.SOA.Serial, true
}
}
}
}
return 0, false
}

View file

@ -0,0 +1,427 @@
// 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"
"encoding/json"
"fmt"
"testing"
"github.com/miekg/dns"
providerReg "git.happydns.org/happyDomain/internal/provider"
domainlogUC "git.happydns.org/happyDomain/internal/usecase/domain_log"
"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"
"git.happydns.org/happyDomain/services/abstract"
// Import AXFRDDNS provider to register its capabilities.
_ "git.happydns.org/happyDomain/providers"
)
// mockDomainUpdater implements DomainUpdater for testing.
type mockDomainUpdater struct {
domain *happydns.Domain
err error
}
func (m *mockDomainUpdater) Update(_ happydns.Identifier, _ *happydns.User, updateFn func(*happydns.Domain)) error {
if m.err != nil {
return m.err
}
if m.domain != nil {
updateFn(m.domain)
}
return nil
}
// inMemoryZoneStorage implements ZoneStorage for testing.
type inMemoryZoneStorage struct {
zones map[string]*happydns.Zone
}
func newInMemoryZoneStorage() *inMemoryZoneStorage {
return &inMemoryZoneStorage{zones: map[string]*happydns.Zone{}}
}
func (s *inMemoryZoneStorage) ListAllZones() (happydns.Iterator[happydns.ZoneMessage], error) {
return nil, fmt.Errorf("not implemented")
}
func (s *inMemoryZoneStorage) GetZoneMeta(zoneid happydns.Identifier) (*happydns.ZoneMeta, error) {
z, ok := s.zones[zoneid.String()]
if !ok {
return nil, fmt.Errorf("zone not found: %s", zoneid)
}
return &z.ZoneMeta, nil
}
func (s *inMemoryZoneStorage) GetZone(zoneid happydns.Identifier) (*happydns.ZoneMessage, error) {
z, ok := s.zones[zoneid.String()]
if !ok {
return nil, fmt.Errorf("zone not found: %s", zoneid)
}
// Convert Zone to ZoneMessage by marshaling services.
msg := &happydns.ZoneMessage{
ZoneMeta: z.ZoneMeta,
Services: map[happydns.Subdomain][]*happydns.ServiceMessage{},
}
for subdn, svcs := range z.Services {
for _, svc := range svcs {
body, err := json.Marshal(svc.Service)
if err != nil {
return nil, err
}
msg.Services[subdn] = append(msg.Services[subdn], &happydns.ServiceMessage{
ServiceMeta: svc.ServiceMeta,
Service: body,
})
}
}
return msg, nil
}
func (s *inMemoryZoneStorage) CreateZone(zone *happydns.Zone) error {
if zone.Id == nil {
zone.Id = happydns.Identifier([]byte(fmt.Sprintf("zone-%d", len(s.zones))))
}
s.zones[zone.Id.String()] = zone
return nil
}
func (s *inMemoryZoneStorage) UpdateZone(zone *happydns.Zone) error {
s.zones[zone.Id.String()] = zone
return nil
}
func (s *inMemoryZoneStorage) DeleteZone(zoneid happydns.Identifier) error {
delete(s.zones, zoneid.String())
return nil
}
func (s *inMemoryZoneStorage) ClearZones() error {
s.zones = map[string]*happydns.Zone{}
return nil
}
// mockZoneRetrieverFailOnNth returns records until the Nth call, then fails.
type mockZoneRetrieverFailOnNth struct {
records []happydns.Record
failOnNth int
failErr error
calls int
}
func (m *mockZoneRetrieverFailOnNth) RetrieveZone(_ context.Context, _ *happydns.Provider, _ string) ([]happydns.Record, error) {
m.calls++
if m.calls >= m.failOnNth {
return nil, m.failErr
}
return m.records, nil
}
// testZoneRetriever is an interface matching orchestrator.ZoneRetriever.
type testZoneRetriever interface {
RetrieveZone(ctx context.Context, provider *happydns.Provider, name string) ([]happydns.Record, error)
}
// buildTestApplier creates a ZoneCorrectionApplierUsecase with the given overrides.
func buildTestApplier(
providerGetter *mockProviderGetter,
zoneCorrector *mockZoneCorrector,
retriever testZoneRetriever,
domainUpdater *mockDomainUpdater,
storage *inMemoryZoneStorage,
) *orchestrator.ZoneCorrectionApplierUsecase {
listRecords := zoneUC.NewListRecordsUsecase(serviceUC.NewListRecordsUsecase())
lister := orchestrator.NewZoneCorrectionListerUsecase(
providerGetter,
listRecords,
zoneCorrector,
retriever,
)
zoneGetter := zoneUC.NewGetZoneUsecase(storage)
zoneCreator := zoneUC.NewCreateZoneUsecase(storage)
zoneUpdater := zoneUC.NewUpdateZoneUsease(storage, zoneGetter)
return orchestrator.NewZoneCorrectionApplierUsecase(
domainlogUC.NoopDomainLogAppender{},
domainUpdater,
lister,
zoneCreator,
zoneGetter,
retriever,
zoneUpdater,
)
}
func TestApply_NoRefetch_WhenProviderLacksCapability(t *testing.T) {
// Provider without manages-soa-serial capability.
provider := &happydns.Provider{
ProviderMeta: happydns.ProviderMeta{
Type: "NoSuchProvider",
},
}
storage := newInMemoryZoneStorage()
wipZoneID := happydns.Identifier([]byte("wip-zone"))
wipZone := &happydns.Zone{
ZoneMeta: happydns.ZoneMeta{
Id: wipZoneID,
DefaultTTL: 3600,
},
Services: map[happydns.Subdomain][]*happydns.Service{},
}
storage.zones[wipZoneID.String()] = wipZone
domain := &happydns.Domain{
Id: happydns.Identifier([]byte("test-domain")),
ProviderId: happydns.Identifier([]byte("test-provider")),
DomainName: "example.com.",
ZoneHistory: []happydns.Identifier{wipZoneID},
}
retriever := &mockZoneRetriever{records: nil}
uc := buildTestApplier(
&mockProviderGetter{provider: provider},
&mockZoneCorrector{corrections: nil, nbDiff: 0},
retriever,
&mockDomainUpdater{domain: domain},
storage,
)
snapshot, err := uc.Apply(
context.Background(),
&happydns.User{Id: happydns.Identifier([]byte("test-user"))},
domain,
wipZone,
&happydns.ApplyZoneForm{
WantedCorrections: nil,
CommitMsg: "test deploy",
},
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if snapshot == nil {
t.Fatal("expected snapshot, got nil")
}
}
func TestApply_Refetch_WhenProviderManagesSOASerial(t *testing.T) {
// Use the DDNSServer type which has manages-soa-serial capability.
// First verify it's registered.
creators := providerReg.GetProviders()
_, hasDDNS := creators["DDNSServer"]
if !hasDDNS {
t.Skip("DDNSServer provider not registered")
}
provider := &happydns.Provider{
ProviderMeta: happydns.ProviderMeta{
Type: "DDNSServer",
},
}
storage := newInMemoryZoneStorage()
// Create WIP zone with an Origin service containing old SOA serial.
wipZoneID := happydns.Identifier([]byte("wip-zone"))
oldSerial := uint32(2024010100)
wipZone := &happydns.Zone{
ZoneMeta: happydns.ZoneMeta{
Id: wipZoneID,
DefaultTTL: 3600,
},
Services: map[happydns.Subdomain][]*happydns.Service{
"": {
{
ServiceMeta: happydns.ServiceMeta{
Id: happydns.Identifier([]byte("origin-svc")),
Type: "abstract.Origin",
},
Service: &abstract.Origin{
SOA: &dns.SOA{
Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 3600},
Ns: "ns1.example.com.",
Mbox: "admin.example.com.",
Serial: oldSerial,
},
NameServers: []*dns.NS{
{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeNS, Class: dns.ClassINET, Ttl: 3600}, Ns: "ns1.example.com."},
},
},
},
},
},
}
storage.zones[wipZoneID.String()] = wipZone
domain := &happydns.Domain{
Id: happydns.Identifier([]byte("test-domain")),
ProviderId: happydns.Identifier([]byte("test-provider")),
DomainName: "example.com.",
ZoneHistory: []happydns.Identifier{wipZoneID},
}
// The re-fetched records contain a new SOA serial.
newSerial := uint32(2024010101)
refetchedRecords := []happydns.Record{
&dns.SOA{
Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 3600},
Ns: "ns1.example.com.",
Mbox: "admin.example.com.",
Serial: newSerial,
},
&dns.NS{
Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeNS, Class: dns.ClassINET, Ttl: 3600},
Ns: "ns1.example.com.",
},
}
retriever := &mockZoneRetriever{records: refetchedRecords}
uc := buildTestApplier(
&mockProviderGetter{provider: provider},
&mockZoneCorrector{corrections: nil, nbDiff: 0},
retriever,
&mockDomainUpdater{domain: domain},
storage,
)
snapshot, err := uc.Apply(
context.Background(),
&happydns.User{Id: happydns.Identifier([]byte("test-user"))},
domain,
wipZone,
&happydns.ApplyZoneForm{
WantedCorrections: nil,
CommitMsg: "test deploy with SOA",
},
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify snapshot has the new serial.
snapshotSerial := getOriginSOASerial(t, snapshot)
if snapshotSerial != newSerial {
t.Errorf("snapshot SOA serial: got %d, want %d", snapshotSerial, newSerial)
}
// Verify WIP zone was patched with new serial.
updatedWIP := storage.zones[wipZoneID.String()]
wipSerial := getOriginSOASerial(t, updatedWIP)
if wipSerial != newSerial {
t.Errorf("WIP zone SOA serial: got %d, want %d", wipSerial, newSerial)
}
}
func TestApply_RefetchFails_FallsBackToTargetRecords(t *testing.T) {
creators := providerReg.GetProviders()
_, hasDDNS := creators["DDNSServer"]
if !hasDDNS {
t.Skip("DDNSServer provider not registered")
}
provider := &happydns.Provider{
ProviderMeta: happydns.ProviderMeta{
Type: "DDNSServer",
},
}
storage := newInMemoryZoneStorage()
wipZoneID := happydns.Identifier([]byte("wip-zone"))
wipZone := &happydns.Zone{
ZoneMeta: happydns.ZoneMeta{
Id: wipZoneID,
DefaultTTL: 3600,
},
Services: map[happydns.Subdomain][]*happydns.Service{},
}
storage.zones[wipZoneID.String()] = wipZone
domain := &happydns.Domain{
Id: happydns.Identifier([]byte("test-domain")),
ProviderId: happydns.Identifier([]byte("test-provider")),
DomainName: "example.com.",
ZoneHistory: []happydns.Identifier{wipZoneID},
}
// Retriever succeeds on first call (lister diff), fails on second (re-fetch).
retriever := &mockZoneRetrieverFailOnNth{
records: nil,
failOnNth: 2,
failErr: fmt.Errorf("connection refused"),
}
uc := buildTestApplier(
&mockProviderGetter{provider: provider},
&mockZoneCorrector{corrections: nil, nbDiff: 0},
retriever,
&mockDomainUpdater{domain: domain},
storage,
)
snapshot, err := uc.Apply(
context.Background(),
&happydns.User{Id: happydns.Identifier([]byte("test-user"))},
domain,
wipZone,
&happydns.ApplyZoneForm{
WantedCorrections: nil,
CommitMsg: "test deploy fallback",
},
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if snapshot == nil {
t.Fatal("expected snapshot, got nil")
}
}
// getOriginSOASerial extracts the SOA serial from the Origin service in a zone.
func getOriginSOASerial(t *testing.T, zone *happydns.Zone) uint32 {
t.Helper()
if services, ok := zone.Services[""]; ok {
for _, svc := range services {
if svc.Type == "abstract.Origin" {
if origin, ok := svc.Service.(*abstract.Origin); ok && origin.SOA != nil {
return origin.SOA.Serial
}
}
}
}
t.Fatal("no Origin service with SOA found in zone")
return 0
}

View file

@ -64,7 +64,7 @@ func (uc *ZoneCorrectionListerUsecase) listWithRecords(
domain *happydns.Domain,
zone *happydns.Zone,
) ([]*happydns.Correction, []happydns.Record, []happydns.Record, int, error) {
provider, err := uc.providerService.GetUserProvider(user, domain.ProviderId)
provider, err := uc.providerService.GetUserProvider(ctx, user, domain.ProviderId)
if err != nil {
return nil, nil, nil, 0, err
}

View file

@ -38,7 +38,7 @@ type mockProviderGetter struct {
err error
}
func (m *mockProviderGetter) GetUserProvider(_ *happydns.User, _ happydns.Identifier) (*happydns.Provider, error) {
func (m *mockProviderGetter) GetUserProvider(_ context.Context, _ *happydns.User, _ happydns.Identifier) (*happydns.Provider, error) {
return m.provider, m.err
}

View file

@ -26,9 +26,9 @@ import (
"log"
"time"
svc "git.happydns.org/happyDomain/internal/service"
zoneUC "git.happydns.org/happyDomain/internal/usecase/zone"
"git.happydns.org/happyDomain/model"
"git.happydns.org/happyDomain/services"
)
// ZoneImporterUsecase converts a flat slice of DNS records into a structured
@ -54,7 +54,7 @@ func NewZoneImporterUsecase(domainUpdater DomainUpdater, zoneCreator *zoneUC.Cre
// 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)
services, defaultTTL, err := svc.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())}
}

View file

@ -22,13 +22,14 @@
package provider
import (
"context"
"fmt"
"git.happydns.org/happyDomain/model"
)
// CreateDomainOnProvider creates a domain on the given provider.
func (s *Service) CreateDomainOnProvider(provider *happydns.Provider, fqdn string) error {
func (s *Service) CreateDomainOnProvider(_ context.Context, provider *happydns.Provider, fqdn string) error {
p, err := instantiate(provider)
if err != nil {
return err
@ -42,7 +43,7 @@ func (s *Service) CreateDomainOnProvider(provider *happydns.Provider, fqdn strin
}
// ListHostedDomains lists all domains hosted on the given provider.
func (s *Service) ListHostedDomains(provider *happydns.Provider) ([]string, error) {
func (s *Service) ListHostedDomains(_ context.Context, provider *happydns.Provider) ([]string, error) {
p, err := instantiate(provider)
if err != nil {
return nil, err
@ -56,7 +57,7 @@ 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 {
func (s *Service) TestDomainExistence(_ context.Context, provider *happydns.Provider, name string) error {
instance, err := instantiate(provider)
if err != nil {
return err

View file

@ -26,8 +26,8 @@ import (
"encoding/json"
"fmt"
providerReg "git.happydns.org/happyDomain/internal/provider"
"git.happydns.org/happyDomain/model"
"git.happydns.org/happyDomain/providers"
)
// Service handles CRUD operations on DNS providers, with ownership enforcement.
@ -53,7 +53,7 @@ func ParseProvider(msg *happydns.ProviderMessage) (p *happydns.Provider, err err
p = &happydns.Provider{}
p.ProviderMeta = msg.ProviderMeta
p.Provider, err = providers.FindProvider(msg.Type)
p.Provider, err = providerReg.FindProvider(msg.Type)
if err != nil {
return
}
@ -72,7 +72,7 @@ func instantiate(p *happydns.Provider) (happydns.ProviderActuator, error) {
}
// CreateProvider creates a new provider for the given user.
func (s *Service) CreateProvider(user *happydns.User, msg *happydns.ProviderMessage) (*happydns.Provider, error) {
func (s *Service) CreateProvider(_ context.Context, user *happydns.User, msg *happydns.ProviderMessage) (*happydns.Provider, error) {
provider, err := ParseProvider(msg)
if err != nil {
return nil, fmt.Errorf("unable to parse provider: %w", err)
@ -109,7 +109,7 @@ func (s *Service) getUserProvider(user *happydns.User, providerID happydns.Ident
}
// GetUserProvider retrieves a provider for the given user.
func (s *Service) GetUserProvider(user *happydns.User, providerID happydns.Identifier) (*happydns.Provider, error) {
func (s *Service) GetUserProvider(_ context.Context, user *happydns.User, providerID happydns.Identifier) (*happydns.Provider, error) {
p, err := s.getUserProvider(user, providerID)
if err != nil {
return nil, err
@ -119,7 +119,7 @@ func (s *Service) GetUserProvider(user *happydns.User, providerID happydns.Ident
}
// GetUserProviderMeta retrieves provider metadata for the given user.
func (s *Service) GetUserProviderMeta(user *happydns.User, providerID happydns.Identifier) (*happydns.ProviderMeta, error) {
func (s *Service) GetUserProviderMeta(_ context.Context, user *happydns.User, providerID happydns.Identifier) (*happydns.ProviderMeta, error) {
p, err := s.getUserProvider(user, providerID)
if err != nil {
return nil, err
@ -129,7 +129,7 @@ func (s *Service) GetUserProviderMeta(user *happydns.User, providerID happydns.I
}
// ListUserProviders retrieves all providers for the given user.
func (s *Service) ListUserProviders(user *happydns.User) ([]*happydns.ProviderMeta, error) {
func (s *Service) ListUserProviders(_ context.Context, user *happydns.User) ([]*happydns.ProviderMeta, error) {
items, err := s.store.ListProviders(user)
if err != nil {
return nil, happydns.InternalError{
@ -147,8 +147,8 @@ func (s *Service) ListUserProviders(user *happydns.User) ([]*happydns.ProviderMe
}
// UpdateProvider updates a provider using the provided update function.
func (s *Service) UpdateProvider(providerID happydns.Identifier, user *happydns.User, updateFn func(*happydns.Provider)) error {
provider, err := s.GetUserProvider(user, providerID)
func (s *Service) UpdateProvider(ctx context.Context, providerID happydns.Identifier, user *happydns.User, updateFn func(*happydns.Provider)) error {
provider, err := s.GetUserProvider(ctx, user, providerID)
if err != nil {
return err
}
@ -176,19 +176,21 @@ func (s *Service) UpdateProvider(providerID happydns.Identifier, user *happydns.
}
// UpdateProviderFromMessage updates a provider from a ProviderMessage.
func (s *Service) UpdateProviderFromMessage(providerID happydns.Identifier, user *happydns.User, p *happydns.ProviderMessage) error {
func (s *Service) UpdateProviderFromMessage(ctx context.Context, providerID happydns.Identifier, user *happydns.User, p *happydns.ProviderMessage) error {
newprovider, err := ParseProvider(p)
if err != nil {
return err
}
return s.UpdateProvider(providerID, user, func(provider *happydns.Provider) {
*provider = *newprovider
return s.UpdateProvider(ctx, providerID, user, func(provider *happydns.Provider) {
provider.Type = newprovider.Type
provider.Comment = newprovider.Comment
provider.Provider = newprovider.Provider
})
}
// DeleteProvider deletes a provider for the given user.
func (s *Service) DeleteProvider(user *happydns.User, providerID happydns.Identifier) error {
func (s *Service) DeleteProvider(_ context.Context, user *happydns.User, providerID happydns.Identifier) error {
// Verify ownership before deleting
if _, err := s.getUserProvider(user, providerID); err != nil {
return err
@ -219,65 +221,65 @@ func NewRestrictedService(cfg *happydns.Options, store ProviderStorage) *Restric
}
// 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) {
func (s *RestrictedService) CreateProvider(ctx context.Context, 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.inner.CreateProvider(user, msg)
return s.inner.CreateProvider(ctx, 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 {
func (s *RestrictedService) DeleteProvider(ctx context.Context, 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.inner.DeleteProvider(user, providerID)
return s.inner.DeleteProvider(ctx, 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 {
func (s *RestrictedService) UpdateProvider(ctx context.Context, 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.inner.UpdateProvider(providerID, user, updateFn)
return s.inner.UpdateProvider(ctx, 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 {
func (s *RestrictedService) UpdateProviderFromMessage(ctx context.Context, 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.inner.UpdateProviderFromMessage(providerID, user, p)
return s.inner.UpdateProviderFromMessage(ctx, providerID, user, p)
}
func (s *RestrictedService) CreateDomainOnProvider(provider *happydns.Provider, fqdn string) error {
func (s *RestrictedService) CreateDomainOnProvider(ctx context.Context, 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)
return s.inner.CreateDomainOnProvider(ctx, 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) GetUserProvider(ctx context.Context, user *happydns.User, providerID happydns.Identifier) (*happydns.Provider, error) {
return s.inner.GetUserProvider(ctx, user, providerID)
}
func (s *RestrictedService) GetUserProviderMeta(user *happydns.User, providerID happydns.Identifier) (*happydns.ProviderMeta, error) {
return s.inner.GetUserProviderMeta(user, providerID)
func (s *RestrictedService) GetUserProviderMeta(ctx context.Context, user *happydns.User, providerID happydns.Identifier) (*happydns.ProviderMeta, error) {
return s.inner.GetUserProviderMeta(ctx, user, providerID)
}
func (s *RestrictedService) ListUserProviders(user *happydns.User) ([]*happydns.ProviderMeta, error) {
return s.inner.ListUserProviders(user)
func (s *RestrictedService) ListUserProviders(ctx context.Context, user *happydns.User) ([]*happydns.ProviderMeta, error) {
return s.inner.ListUserProviders(ctx, user)
}
func (s *RestrictedService) ListHostedDomains(provider *happydns.Provider) ([]string, error) {
return s.inner.ListHostedDomains(provider)
func (s *RestrictedService) ListHostedDomains(ctx context.Context, provider *happydns.Provider) ([]string, error) {
return s.inner.ListHostedDomains(ctx, provider)
}
func (s *RestrictedService) ListZoneCorrections(ctx context.Context, provider *happydns.Provider, domain *happydns.Domain, records []happydns.Record) ([]*happydns.Correction, int, error) {
@ -288,6 +290,6 @@ func (s *RestrictedService) RetrieveZone(ctx context.Context, provider *happydns
return s.inner.RetrieveZone(ctx, provider, name)
}
func (s *RestrictedService) TestDomainExistence(provider *happydns.Provider, name string) error {
return s.inner.TestDomainExistence(provider, name)
func (s *RestrictedService) TestDomainExistence(ctx context.Context, provider *happydns.Provider, name string) error {
return s.inner.TestDomainExistence(ctx, provider, name)
}

View file

@ -22,6 +22,7 @@
package provider_test
import (
"context"
"encoding/json"
"testing"
@ -33,6 +34,8 @@ import (
"git.happydns.org/happyDomain/providers"
)
var ctx = context.Background()
func createTestUser(t *testing.T, store storage.Storage, email string) *happydns.User {
user := &happydns.User{
Id: happydns.Identifier([]byte("user-" + email)),
@ -86,7 +89,7 @@ func Test_CreateProvider(t *testing.T) {
user := createTestUser(t, db, "test@example.com")
msg := createTestProviderMessage(t, "DDNSServer", "Test DDNS Provider")
p, err := providerService.CreateProvider(user, msg)
p, err := providerService.CreateProvider(ctx, user, msg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@ -118,13 +121,13 @@ func Test_GetUserProvider(t *testing.T) {
// Create a provider
msg := createTestProviderMessage(t, "DDNSServer", "Test Provider")
createdProvider, err := providerService.CreateProvider(user, msg)
createdProvider, err := providerService.CreateProvider(ctx, user, msg)
if err != nil {
t.Fatalf("unexpected error creating provider: %v", err)
}
// Retrieve the provider
retrievedProvider, err := providerService.GetUserProvider(user, createdProvider.Id)
retrievedProvider, err := providerService.GetUserProvider(ctx, user, createdProvider.Id)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@ -145,13 +148,13 @@ func Test_GetUserProvider_WrongUser(t *testing.T) {
// Create a provider for user1
msg := createTestProviderMessage(t, "DDNSServer", "User1 Provider")
createdProvider, err := providerService.CreateProvider(user1, msg)
createdProvider, err := providerService.CreateProvider(ctx, user1, msg)
if err != nil {
t.Fatalf("unexpected error creating provider: %v", err)
}
// Try to retrieve the provider as user2
_, err = providerService.GetUserProvider(user2, createdProvider.Id)
_, err = providerService.GetUserProvider(ctx, user2, createdProvider.Id)
if err == nil {
t.Error("expected error when retrieving another user's provider")
}
@ -166,7 +169,7 @@ func Test_GetUserProvider_NotFound(t *testing.T) {
user := createTestUser(t, db, "test@example.com")
nonexistentID := happydns.Identifier([]byte("nonexistent-id"))
_, err := providerService.GetUserProvider(user, nonexistentID)
_, err := providerService.GetUserProvider(ctx, user, nonexistentID)
if err == nil {
t.Error("expected error when retrieving nonexistent provider")
}
@ -182,13 +185,13 @@ func Test_GetUserProviderMeta(t *testing.T) {
// Create a provider
msg := createTestProviderMessage(t, "DDNSServer", "Test Provider Meta")
createdProvider, err := providerService.CreateProvider(user, msg)
createdProvider, err := providerService.CreateProvider(ctx, user, msg)
if err != nil {
t.Fatalf("unexpected error creating provider: %v", err)
}
// Retrieve the provider metadata
meta, err := providerService.GetUserProviderMeta(user, createdProvider.Id)
meta, err := providerService.GetUserProviderMeta(ctx, user, createdProvider.Id)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@ -207,21 +210,21 @@ func Test_ListUserProviders(t *testing.T) {
user := createTestUser(t, db, "test@example.com")
// Create multiple providers
_, err := providerService.CreateProvider(user, createTestProviderMessage(t, "DDNSServer", "Provider 1"))
_, err := providerService.CreateProvider(ctx, user, createTestProviderMessage(t, "DDNSServer", "Provider 1"))
if err != nil {
t.Fatalf("unexpected error creating provider 1: %v", err)
}
_, err = providerService.CreateProvider(user, createTestProviderMessage(t, "DDNSServer", "Provider 2"))
_, err = providerService.CreateProvider(ctx, user, createTestProviderMessage(t, "DDNSServer", "Provider 2"))
if err != nil {
t.Fatalf("unexpected error creating provider 2: %v", err)
}
_, err = providerService.CreateProvider(user, createTestProviderMessage(t, "DDNSServer", "Provider 3"))
_, err = providerService.CreateProvider(ctx, user, createTestProviderMessage(t, "DDNSServer", "Provider 3"))
if err != nil {
t.Fatalf("unexpected error creating provider 3: %v", err)
}
// List providers
providers, err := providerService.ListUserProviders(user)
providers, err := providerService.ListUserProviders(ctx, user)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@ -238,23 +241,23 @@ func Test_ListUserProviders_MultipleUsers(t *testing.T) {
user2 := createTestUser(t, db, "user2@example.com")
// Create providers for user1
_, err := providerService.CreateProvider(user1, createTestProviderMessage(t, "DDNSServer", "User1 Provider 1"))
_, err := providerService.CreateProvider(ctx, user1, createTestProviderMessage(t, "DDNSServer", "User1 Provider 1"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
_, err = providerService.CreateProvider(user1, createTestProviderMessage(t, "DDNSServer", "User1 Provider 2"))
_, err = providerService.CreateProvider(ctx, user1, createTestProviderMessage(t, "DDNSServer", "User1 Provider 2"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Create provider for user2
_, err = providerService.CreateProvider(user2, createTestProviderMessage(t, "DDNSServer", "User2 Provider 1"))
_, err = providerService.CreateProvider(ctx, user2, createTestProviderMessage(t, "DDNSServer", "User2 Provider 1"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// List providers for user1
user1Providers, err := providerService.ListUserProviders(user1)
user1Providers, err := providerService.ListUserProviders(ctx, user1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@ -263,7 +266,7 @@ func Test_ListUserProviders_MultipleUsers(t *testing.T) {
}
// List providers for user2
user2Providers, err := providerService.ListUserProviders(user2)
user2Providers, err := providerService.ListUserProviders(ctx, user2)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@ -279,13 +282,13 @@ func Test_UpdateProvider(t *testing.T) {
// Create a provider
msg := createTestProviderMessage(t, "DDNSServer", "Original comment")
createdProvider, err := providerService.CreateProvider(user, msg)
createdProvider, err := providerService.CreateProvider(ctx, user, msg)
if err != nil {
t.Fatalf("unexpected error creating provider: %v", err)
}
// Update the provider
err = providerService.UpdateProvider(createdProvider.Id, user, func(p *happydns.Provider) {
err = providerService.UpdateProvider(ctx, createdProvider.Id, user, func(p *happydns.Provider) {
p.Comment = "Updated comment"
})
if err != nil {
@ -293,7 +296,7 @@ func Test_UpdateProvider(t *testing.T) {
}
// Verify the provider was updated
updated, err := providerService.GetUserProvider(user, createdProvider.Id)
updated, err := providerService.GetUserProvider(ctx, user, createdProvider.Id)
if err != nil {
t.Fatalf("unexpected error retrieving updated provider: %v", err)
}
@ -309,14 +312,14 @@ func Test_UpdateProvider_PreventIdChange(t *testing.T) {
// Create a provider
msg := createTestProviderMessage(t, "DDNSServer", "Test Provider")
createdProvider, err := providerService.CreateProvider(user, msg)
createdProvider, err := providerService.CreateProvider(ctx, user, msg)
if err != nil {
t.Fatalf("unexpected error creating provider: %v", err)
}
// Try to change the provider ID
newID := happydns.Identifier([]byte("new-provider-id"))
err = providerService.UpdateProvider(createdProvider.Id, user, func(p *happydns.Provider) {
err = providerService.UpdateProvider(ctx, createdProvider.Id, user, func(p *happydns.Provider) {
p.Id = newID
})
if err == nil {
@ -335,13 +338,13 @@ func Test_UpdateProvider_WrongUser(t *testing.T) {
// Create a provider for user1
msg := createTestProviderMessage(t, "DDNSServer", "User1 Provider")
createdProvider, err := providerService.CreateProvider(user1, msg)
createdProvider, err := providerService.CreateProvider(ctx, user1, msg)
if err != nil {
t.Fatalf("unexpected error creating provider: %v", err)
}
// Try to update the provider as user2
err = providerService.UpdateProvider(createdProvider.Id, user2, func(p *happydns.Provider) {
err = providerService.UpdateProvider(ctx, createdProvider.Id, user2, func(p *happydns.Provider) {
p.Comment = "Hijacked"
})
if err == nil {
@ -356,19 +359,19 @@ func Test_DeleteProvider(t *testing.T) {
// Create a provider
msg := createTestProviderMessage(t, "DDNSServer", "Test Provider")
createdProvider, err := providerService.CreateProvider(user, msg)
createdProvider, err := providerService.CreateProvider(ctx, user, msg)
if err != nil {
t.Fatalf("unexpected error creating provider: %v", err)
}
// Delete the provider
err = providerService.DeleteProvider(user, createdProvider.Id)
err = providerService.DeleteProvider(ctx, user, createdProvider.Id)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify the provider was deleted
_, err = providerService.GetUserProvider(user, createdProvider.Id)
_, err = providerService.GetUserProvider(ctx, user, createdProvider.Id)
if err == nil {
t.Error("expected error when retrieving deleted provider")
}
@ -385,13 +388,13 @@ func Test_DeleteProvider_WrongUser(t *testing.T) {
// Create a provider for user1
msg := createTestProviderMessage(t, "DDNSServer", "User1 Provider")
createdProvider, err := providerService.CreateProvider(user1, msg)
createdProvider, err := providerService.CreateProvider(ctx, 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)
err = providerService.DeleteProvider(ctx, user2, createdProvider.Id)
if err == nil {
t.Error("expected error when deleting another user's provider")
}
@ -400,7 +403,7 @@ func Test_DeleteProvider_WrongUser(t *testing.T) {
}
// Verify the provider still exists for user1
_, err = providerService.GetUserProvider(user1, createdProvider.Id)
_, err = providerService.GetUserProvider(ctx, user1, createdProvider.Id)
if err != nil {
t.Errorf("provider should still exist for user1, got error: %v", err)
}
@ -450,7 +453,7 @@ func Test_RestrictedService_CreateProvider_Disabled(t *testing.T) {
user := createTestUser(t, db, "test@example.com")
msg := createTestProviderMessage(t, "DDNSServer", "Test Provider")
_, err := providerService.CreateProvider(user, msg)
_, err := providerService.CreateProvider(ctx, user, msg)
if err == nil {
t.Error("expected error when creating provider with DisableProviders=true")
}
@ -467,7 +470,7 @@ func Test_RestrictedService_UpdateProvider_Disabled(t *testing.T) {
unrestricted := provider.NewService(db, &mockValidator{})
user := createTestUser(t, db, "test@example.com")
msg := createTestProviderMessage(t, "DDNSServer", "Test Provider")
createdProvider, err := unrestricted.CreateProvider(user, msg)
createdProvider, err := unrestricted.CreateProvider(ctx, user, msg)
if err != nil {
t.Fatalf("unexpected error creating provider: %v", err)
}
@ -478,7 +481,7 @@ func Test_RestrictedService_UpdateProvider_Disabled(t *testing.T) {
}
restrictedService := provider.NewRestrictedService(config, db)
err = restrictedService.UpdateProvider(createdProvider.Id, user, func(p *happydns.Provider) {
err = restrictedService.UpdateProvider(ctx, createdProvider.Id, user, func(p *happydns.Provider) {
p.Comment = "Updated"
})
if err == nil {
@ -497,7 +500,7 @@ func Test_RestrictedService_DeleteProvider_Disabled(t *testing.T) {
unrestricted := provider.NewService(db, &mockValidator{})
user := createTestUser(t, db, "test@example.com")
msg := createTestProviderMessage(t, "DDNSServer", "Test Provider")
createdProvider, err := unrestricted.CreateProvider(user, msg)
createdProvider, err := unrestricted.CreateProvider(ctx, user, msg)
if err != nil {
t.Fatalf("unexpected error creating provider: %v", err)
}
@ -508,7 +511,7 @@ func Test_RestrictedService_DeleteProvider_Disabled(t *testing.T) {
}
restrictedService := provider.NewRestrictedService(config, db)
err = restrictedService.DeleteProvider(user, createdProvider.Id)
err = restrictedService.DeleteProvider(ctx, user, createdProvider.Id)
if err == nil {
t.Error("expected error when deleting provider with DisableProviders=true")
}

View file

@ -22,6 +22,7 @@
package usecase
import (
"context"
"encoding/json"
"fmt"
@ -41,7 +42,7 @@ func NewProviderSettingsUsecase(cfg *happydns.Options, ps happydns.ProviderUseca
}
}
func (psu *providerSettingsUsecase) NextProviderSettingsState(state *happydns.ProviderSettingsState, pType string, user *happydns.User) (*happydns.Provider, *happydns.ProviderSettingsResponse, error) {
func (psu *providerSettingsUsecase) NextProviderSettingsState(ctx context.Context, state *happydns.ProviderSettingsState, pType string, user *happydns.User) (*happydns.Provider, *happydns.ProviderSettingsResponse, error) {
fu := NewFormUsecase(psu.config)
form, p, err := forms.DoSettingState(fu, &state.FormState, state.ProviderBody, forms.GenDefaultSettingsForm)
@ -71,7 +72,7 @@ func (psu *providerSettingsUsecase) NextProviderSettingsState(state *happydns.Pr
if state.Id == nil {
// Create a new Provider via the service layer
provider, err := psu.providerService.CreateProvider(user, msg)
provider, err := psu.providerService.CreateProvider(ctx, user, msg)
if err != nil {
return nil, nil, err
}
@ -79,12 +80,12 @@ func (psu *providerSettingsUsecase) NextProviderSettingsState(state *happydns.Pr
return provider, nil, nil
} else {
// Update an existing Provider via the service layer
err := psu.providerService.UpdateProviderFromMessage(*state.Id, user, msg)
err := psu.providerService.UpdateProviderFromMessage(ctx, *state.Id, user, msg)
if err != nil {
return nil, nil, err
}
provider, err := psu.providerService.GetUserProvider(user, *state.Id)
provider, err := psu.providerService.GetUserProvider(ctx, user, *state.Id)
if err != nil {
return nil, nil, err
}

View file

@ -25,6 +25,7 @@ import (
"strings"
"git.happydns.org/happyDomain/internal/forms"
providerReg "git.happydns.org/happyDomain/internal/provider"
"git.happydns.org/happyDomain/model"
"git.happydns.org/happyDomain/providers"
)
@ -37,10 +38,10 @@ func NewProviderSpecsUsecase() happydns.ProviderSpecsUsecase {
}
func (psu *providerSpecsUsecase) ListProviders() map[string]happydns.ProviderInfos {
srcs := providers.GetProviders()
srcs := providerReg.GetProviders()
ret := map[string]happydns.ProviderInfos{}
for k, src := range *srcs {
for k, src := range srcs {
ret[k] = src.Infos
}
@ -57,7 +58,7 @@ func (psu *providerSpecsUsecase) GetProviderIcon(psid string) ([]byte, error) {
}
func (psu *providerSpecsUsecase) GetProviderSpecs(psid string) (*happydns.ProviderSpecs, error) {
pcreator, ok := (*providers.GetProviders())[psid]
pcreator, ok := providerReg.GetProviders()[psid]
if !ok {
return nil, happydns.NotFoundError{Msg: happydns.ErrProviderNotFound.Error()}
}

View file

@ -22,6 +22,9 @@
package service
import (
"fmt"
"reflect"
"git.happydns.org/happyDomain/internal/helpers"
"git.happydns.org/happyDomain/model"
)
@ -45,7 +48,7 @@ func (uc *ListRecordsUsecase) List(svc *happydns.Service, origin string, default
records, err := svc.Service.GetRecords(svc.Domain, defaultTTL, origin)
if err != nil {
return nil, err
return nil, fmt.Errorf("during %s generation: %w", reflect.TypeOf(svc.Service).Elem().Name(), err)
}
for i, record := range records {

View file

@ -6,9 +6,10 @@ import (
"github.com/miekg/dns"
intsvc "git.happydns.org/happyDomain/internal/service"
"git.happydns.org/happyDomain/internal/usecase/service"
"git.happydns.org/happyDomain/model"
"git.happydns.org/happyDomain/services"
_ "git.happydns.org/happyDomain/services"
)
func TestListOneRecord(t *testing.T) {
@ -25,7 +26,7 @@ func TestListOneRecord(t *testing.T) {
txt := happydns.NewTXT(rr.(*dns.TXT))
s, _, err := svcs.AnalyzeZone(origin, []happydns.Record{txt})
s, _, err := intsvc.AnalyzeZone(origin, []happydns.Record{txt})
if err != nil {
t.Fatalf("AnalyzeZone failed: %v", err)
}
@ -74,7 +75,7 @@ func TestListRecordDefaultTTL(t *testing.T) {
txt := happydns.NewTXT(rr.(*dns.TXT))
s, _, err := svcs.AnalyzeZone(origin, []happydns.Record{txt})
s, _, err := intsvc.AnalyzeZone(origin, []happydns.Record{txt})
if err != nil {
t.Fatalf("AnalyzeZone failed: %v", err)
}
@ -102,7 +103,7 @@ func TestListRecordRelative(t *testing.T) {
txt := happydns.NewTXT(rr.(*dns.TXT))
s, _, err := svcs.AnalyzeZone("", []happydns.Record{txt})
s, _, err := intsvc.AnalyzeZone("", []happydns.Record{txt})
if err != nil {
t.Fatalf("AnalyzeZone failed: %v", err)
}

View file

@ -24,8 +24,9 @@ package service
import (
"encoding/json"
"git.happydns.org/happyDomain/internal/forms"
intsvc "git.happydns.org/happyDomain/internal/service"
"git.happydns.org/happyDomain/model"
"git.happydns.org/happyDomain/services"
)
// ParseService deserialises a ServiceMessage into a typed Service value.
@ -35,11 +36,16 @@ func ParseService(msg *happydns.ServiceMessage) (svc *happydns.Service, err erro
svc = &happydns.Service{}
svc.ServiceMeta = msg.ServiceMeta
svc.Service, err = svcs.FindService(msg.Type)
svc.Service, err = intsvc.FindService(msg.Type)
if err != nil {
return
}
err = json.Unmarshal(msg.Service, &svc.Service)
if err != nil {
return
}
err = forms.ValidateStructValues(svc.Service)
return
}

View file

@ -7,9 +7,10 @@ import (
"github.com/miekg/dns"
"git.happydns.org/happyDomain/internal/helpers"
intsvc "git.happydns.org/happyDomain/internal/service"
"git.happydns.org/happyDomain/internal/usecase/service"
"git.happydns.org/happyDomain/model"
"git.happydns.org/happyDomain/services"
_ "git.happydns.org/happyDomain/services"
)
func TestExistsInService(t *testing.T) {
@ -25,7 +26,7 @@ func TestExistsInService(t *testing.T) {
txt := happydns.NewTXT(rr.(*dns.TXT))
s, _, err := svcs.AnalyzeZone(origin, []happydns.Record{txt})
s, _, err := intsvc.AnalyzeZone(origin, []happydns.Record{txt})
if err != nil {
t.Fatalf("AnalyzeZone failed: %v", err)
}
@ -63,7 +64,7 @@ func TestNotExistsInService(t *testing.T) {
txt := happydns.NewTXT(rr.(*dns.TXT))
s, _, err := svcs.AnalyzeZone(origin, []happydns.Record{txt})
s, _, err := intsvc.AnalyzeZone(origin, []happydns.Record{txt})
if err != nil {
t.Fatalf("AnalyzeZone failed: %v", err)
}
@ -104,7 +105,7 @@ func TestExistsInRelativeService(t *testing.T) {
txt := happydns.NewTXT(rr.(*dns.TXT))
s, _, err := svcs.AnalyzeZone("", []happydns.Record{txt})
s, _, err := intsvc.AnalyzeZone("", []happydns.Record{txt})
if err != nil {
t.Fatalf("AnalyzeZone failed: %v", err)
}

View file

@ -29,8 +29,9 @@ import (
"github.com/miekg/dns"
intsvc "git.happydns.org/happyDomain/internal/service"
"git.happydns.org/happyDomain/model"
"git.happydns.org/happyDomain/services"
svcs "git.happydns.org/happyDomain/services"
)
// serviceSpecsUsecase implements happydns.ServiceSpecsUsecase, providing
@ -47,7 +48,7 @@ func NewServiceSpecsUsecase() happydns.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()
services := intsvc.ListServices()
ret := map[string]happydns.ServiceInfos{}
for k, service := range *services {

View file

@ -69,7 +69,7 @@ func (tu *tidyUpUsecase) TidyAuthUsers() error {
}
}
return nil
return iter.Err()
}
func (tu *tidyUpUsecase) TidyDomains() error {
@ -99,7 +99,7 @@ func (tu *tidyUpUsecase) TidyDomains() error {
}
}
return nil
return iter.Err()
}
func (tu *tidyUpUsecase) TidyDomainLogs() error {
@ -121,7 +121,7 @@ func (tu *tidyUpUsecase) TidyDomainLogs() error {
}
}
return nil
return iter.Err()
}
func (tu *tidyUpUsecase) TidyProviders() error {
@ -144,7 +144,7 @@ func (tu *tidyUpUsecase) TidyProviders() error {
}
}
return nil
return iter.Err()
}
func (tu *tidyUpUsecase) TidySessions() error {
@ -167,7 +167,7 @@ func (tu *tidyUpUsecase) TidySessions() error {
}
}
return nil
return iter.Err()
}
func (tu *tidyUpUsecase) TidyUsers() error {
@ -190,6 +190,10 @@ func (tu *tidyUpUsecase) TidyZones() error {
}
}
if err = iterdn.Err(); err != nil {
return err
}
iter, err := tu.store.ListAllZones()
if err != nil {
return err
@ -216,5 +220,5 @@ func (tu *tidyUpUsecase) TidyZones() error {
}
}
return nil
return iter.Err()
}

View file

@ -23,9 +23,9 @@ package zone
import (
"git.happydns.org/happyDomain/internal/helpers"
intsvc "git.happydns.org/happyDomain/internal/service"
"git.happydns.org/happyDomain/internal/usecase/service"
"git.happydns.org/happyDomain/model"
"git.happydns.org/happyDomain/services"
)
// AddRecordUsecase handles adding a single DNS record to an in-memory Zone,
@ -52,7 +52,7 @@ func (uc *AddRecordUsecase) Add(zone *happydns.Zone, origin string, record happy
record.Header().Name = helpers.DomainFQDN(record.Header().Name, origin)
// Research the service in which the record should be found
newsvc, _, err := svcs.AnalyzeZone(origin, []happydns.Record{record})
newsvc, _, err := intsvc.AnalyzeZone(origin, []happydns.Record{record})
if err != nil {
return err
}
@ -74,7 +74,7 @@ func (uc *AddRecordUsecase) Add(zone *happydns.Zone, origin string, record happy
svc_rrs = append([]happydns.Record{record}, svc_rrs...)
// Recreate the service
mergedsvc, _, err := svcs.AnalyzeZone(origin, svc_rrs)
mergedsvc, _, err := intsvc.AnalyzeZone(origin, svc_rrs)
if err != nil {
return err
}

View file

@ -26,9 +26,10 @@ import (
"reflect"
"git.happydns.org/happyDomain/internal/helpers"
intsvc "git.happydns.org/happyDomain/internal/service"
"git.happydns.org/happyDomain/internal/usecase/service"
"git.happydns.org/happyDomain/model"
"git.happydns.org/happyDomain/services"
svcs "git.happydns.org/happyDomain/services"
)
// DeleteRecordUsecase handles removing a single DNS record from an in-memory
@ -75,7 +76,7 @@ func (uc *DeleteRecordUsecase) delete(zone *happydns.Zone, origin string, record
if len(svc_rrs) > 0 {
// Recreate the service
newsvc, _, err = svcs.AnalyzeZone(origin, svc_rrs)
newsvc, _, err = intsvc.AnalyzeZone(origin, svc_rrs)
if err != nil {
return err
}
@ -150,7 +151,7 @@ func (uc *DeleteRecordUsecase) ReanalyzeOrphan(zone *happydns.Zone, origin strin
}
// Redo analysis
newsvcs, _, err := svcs.AnalyzeZone(origin, records)
newsvcs, _, err := intsvc.AnalyzeZone(origin, records)
if err != nil {
return err
}

View file

@ -65,6 +65,7 @@ func transferMetadata(oldSvc, newSvc *happydns.Service, origin string, defaultTT
newSvc.UserComment = oldSvc.UserComment
newSvc.OwnerId = oldSvc.OwnerId
newSvc.Aliases = oldSvc.Aliases
newSvc.PropagatedAt = oldSvc.PropagatedAt
if oldSvc.Ttl != 0 {
serviceTtl := oldSvc.Ttl

View file

@ -30,13 +30,13 @@ import (
// UserAuth represents an account used for authentication (not used in case of external auth).
type UserAuth struct {
// Id is the User's identifier.
Id Identifier `json:"id" swaggertype:"string"`
Id Identifier `json:"id" swaggertype:"string" readonly:"true"`
// Email is the User's login and mean of contact.
Email string `json:"email"`
// EmailVerification is the time when the User verify its email address.
EmailVerification *time.Time `json:"emailVerification,omitempty"`
EmailVerification *time.Time `json:"emailVerification,omitempty" format:"date-time"`
// Password is hashed.
Password []byte `json:"password,omitempty"`
@ -45,10 +45,10 @@ type UserAuth struct {
PasswordRecoveryKey []byte `json:"passwordRecoveryKey,omitempty"`
// CreatedAt is the time when the User has register is account.
CreatedAt time.Time `json:"createdAt"`
CreatedAt time.Time `json:"createdAt" format:"date-time" readonly:"true"`
// LastLoggedIn is the time when the User has logged in for the last time.
LastLoggedIn *time.Time `json:"lastLoggedIn,omitempty"`
LastLoggedIn *time.Time `json:"lastLoggedIn,omitempty" format:"date-time"`
// AllowCommercials stores the user preference regarding email contacts.
AllowCommercials bool `json:"allowCommercials"`

298
model/checker.go Normal file
View file

@ -0,0 +1,298 @@
// 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 happydns
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// The types and helpers needed by external checker plugins live in the
// Apache-2.0 licensed checker-sdk-go module. They are re-exported here as
// aliases so the rest of the happyDomain codebase keeps working unchanged.
//
// Host-only types (Execution, CheckPlan, CheckEvaluation, …) remain
// defined in this file because they describe orchestration state that is
// internal to the happyDomain server and never crosses the plugin boundary.
// --- Re-exports from checker-sdk-go ---
type CheckScopeType = sdk.CheckScopeType
const (
CheckScopeAdmin = sdk.CheckScopeAdmin
CheckScopeUser = sdk.CheckScopeUser
CheckScopeDomain = sdk.CheckScopeDomain
CheckScopeZone = sdk.CheckScopeZone
CheckScopeService = sdk.CheckScopeService
)
const (
AutoFillDomainName = sdk.AutoFillDomainName
AutoFillSubdomain = sdk.AutoFillSubdomain
AutoFillZone = sdk.AutoFillZone
AutoFillServiceType = sdk.AutoFillServiceType
AutoFillService = sdk.AutoFillService
)
type (
CheckTarget = sdk.CheckTarget
CheckerAvailability = sdk.CheckerAvailability
CheckerOptions = sdk.CheckerOptions
CheckerOptionDocumentation = sdk.CheckerOptionDocumentation
CheckerOptionsDocumentation = sdk.CheckerOptionsDocumentation
Status = sdk.Status
CheckState = sdk.CheckState
CheckMetric = sdk.CheckMetric
ObservationKey = sdk.ObservationKey
CheckIntervalSpec = sdk.CheckIntervalSpec
ObservationProvider = sdk.ObservationProvider
CheckRuleInfo = sdk.CheckRuleInfo
CheckRule = sdk.CheckRule
CheckRuleWithOptions = sdk.CheckRuleWithOptions
ObservationGetter = sdk.ObservationGetter
CheckAggregator = sdk.CheckAggregator
CheckerHTMLReporter = sdk.CheckerHTMLReporter
CheckerMetricsReporter = sdk.CheckerMetricsReporter
CheckerDefinitionProvider = sdk.CheckerDefinitionProvider
CheckerDefinition = sdk.CheckerDefinition
OptionsValidator = sdk.OptionsValidator
ExternalCollectRequest = sdk.ExternalCollectRequest
ExternalCollectResponse = sdk.ExternalCollectResponse
ExternalEvaluateRequest = sdk.ExternalEvaluateRequest
ExternalEvaluateResponse = sdk.ExternalEvaluateResponse
ExternalReportRequest = sdk.ExternalReportRequest
)
const (
StatusUnknown = sdk.StatusUnknown
StatusOK = sdk.StatusOK
StatusInfo = sdk.StatusInfo
StatusWarn = sdk.StatusWarn
StatusCrit = sdk.StatusCrit
StatusError = sdk.StatusError
)
// --- Helpers for converting between target identifier strings and *Identifier ---
// TargetIdentifier parses a target identifier string into an *Identifier.
// Returns nil if the string is empty or cannot be parsed.
func TargetIdentifier(s string) *Identifier {
if s == "" {
return nil
}
id, err := NewIdentifierFromString(s)
if err != nil {
return nil
}
return &id
}
// FormatIdentifier returns the string representation of id, or "" if nil.
func FormatIdentifier(id *Identifier) string {
if id == nil {
return ""
}
return id.String()
}
// --- Host-only types (orchestration state) ---
// CheckerRunRequest is the JSON body for manually triggering a checker.
type CheckerRunRequest struct {
Options CheckerOptions `json:"options,omitempty"`
EnabledRules map[string]bool `json:"enabledRules,omitempty"`
}
// CheckerOptionsPositional stores options with their positional key components.
type CheckerOptionsPositional struct {
CheckName string `json:"checkName"`
UserId *Identifier `json:"userId,omitempty"`
DomainId *Identifier `json:"domainId,omitempty"`
ServiceId *Identifier `json:"serviceId,omitempty"`
Options CheckerOptions `json:"options"`
}
// CheckPlan is an optional user override for a checker on a specific target.
type CheckPlan struct {
Id Identifier `json:"id" swaggertype:"string" binding:"required" readonly:"true"`
CheckerID string `json:"checkerId" binding:"required" readonly:"true"`
Target CheckTarget `json:"target" binding:"required" readonly:"true"`
Interval *time.Duration `json:"interval,omitempty" swaggertype:"integer"`
Enabled map[string]bool `json:"enabled,omitempty"`
}
// IsFullyDisabled returns true if the enabled map is non-empty and every entry is false.
func (p *CheckPlan) IsFullyDisabled() bool {
if len(p.Enabled) == 0 {
return false
}
for _, v := range p.Enabled {
if v {
return false
}
}
return true
}
// IsRuleEnabled returns whether a specific rule is enabled.
// A nil or empty map means all rules are enabled. A missing key means enabled.
func (p *CheckPlan) IsRuleEnabled(ruleName string) bool {
if len(p.Enabled) == 0 {
return true
}
v, ok := p.Enabled[ruleName]
if !ok {
return true
}
return v
}
// CheckerStatus combines a checker definition with its latest execution and plan for a target.
type CheckerStatus struct {
*CheckerDefinition
LatestExecution *Execution `json:"latestExecution,omitempty"`
Plan *CheckPlan `json:"plan,omitempty"`
Enabled bool `json:"enabled"`
EnabledRules map[string]bool `json:"enabledRules"`
}
// CheckEvaluation is the result of running a checker on observed data.
type CheckEvaluation struct {
Id Identifier `json:"id" swaggertype:"string" binding:"required" readonly:"true"`
PlanID *Identifier `json:"planId,omitempty" swaggertype:"string"`
CheckerID string `json:"checkerId" binding:"required"`
Target CheckTarget `json:"target" binding:"required"`
SnapshotID Identifier `json:"snapshotId" swaggertype:"string" binding:"required" readonly:"true"`
EvaluatedAt time.Time `json:"evaluatedAt" binding:"required" readonly:"true" format:"date-time"`
States []CheckState `json:"states" binding:"required" readonly:"true"`
}
// ObservationSnapshot holds data collected during an execution.
type ObservationSnapshot struct {
Id Identifier `json:"id" swaggertype:"string" binding:"required" readonly:"true"`
Target CheckTarget `json:"target" binding:"required" readonly:"true"`
CollectedAt time.Time `json:"collectedAt" binding:"required" readonly:"true" format:"date-time"`
Data map[ObservationKey]json.RawMessage `json:"data" binding:"required" readonly:"true" swaggertype:"object,object"`
}
// ObservationCacheEntry is a lightweight pointer to cached observation data in a snapshot.
type ObservationCacheEntry struct {
SnapshotID Identifier `json:"snapshotId"`
CollectedAt time.Time `json:"collectedAt"`
}
// ExecutionStatus represents the lifecycle state of an execution.
type ExecutionStatus int
const (
ExecutionPending ExecutionStatus = iota
ExecutionRunning
ExecutionDone
ExecutionFailed
)
// TriggerType represents what initiated an execution.
type TriggerType int
const (
TriggerManual TriggerType = iota
TriggerSchedule
)
// TriggerInfo describes the trigger for an execution.
type TriggerInfo struct {
Type TriggerType `json:"type"`
PlanID *Identifier `json:"planId,omitempty" swaggertype:"string"`
}
// Execution represents a single run of a checker pipeline.
type Execution struct {
Id Identifier `json:"id" swaggertype:"string" binding:"required" readonly:"true"`
CheckerID string `json:"checkerId" binding:"required" readonly:"true"`
PlanID *Identifier `json:"planId,omitempty" swaggertype:"string" readonly:"true"`
Target CheckTarget `json:"target" binding:"required" readonly:"true"`
Trigger TriggerInfo `json:"trigger" binding:"required" readonly:"true"`
StartedAt time.Time `json:"startedAt" binding:"required" readonly:"true" format:"date-time"`
EndedAt *time.Time `json:"endedAt,omitempty" readonly:"true" format:"date-time"`
Status ExecutionStatus `json:"status" binding:"required" readonly:"true"`
Error string `json:"error,omitempty" readonly:"true"`
Result CheckState `json:"result" readonly:"true"`
EvaluationID *Identifier `json:"evaluationId,omitempty" swaggertype:"string" readonly:"true"`
}
// CheckerEngine orchestrates the full checker pipeline.
type CheckerEngine interface {
CreateExecution(checkerID string, target CheckTarget, plan *CheckPlan) (*Execution, error)
RunExecution(ctx context.Context, exec *Execution, plan *CheckPlan, runOpts CheckerOptions) (*CheckEvaluation, error)
}
// CheckerOptionsKey builds the positional KV key for checker options.
// Format: chckrcfg-{checkerName}/{userId}/{domainId}/{serviceId}
func CheckerOptionsKey(checkerName string, userId *Identifier, domainId *Identifier, serviceId *Identifier) string {
uid := ""
if userId != nil {
uid = userId.String()
}
did := ""
if domainId != nil {
did = domainId.String()
}
sid := ""
if serviceId != nil {
sid = serviceId.String()
}
return fmt.Sprintf("chckrcfg-%s/%s/%s/%s", checkerName, uid, did, sid)
}
// ParseCheckerOptionsKey extracts the positional components from a KV key.
func ParseCheckerOptionsKey(key string) (checkerName string, userId *Identifier, domainId *Identifier, serviceId *Identifier) {
trimmed := strings.TrimPrefix(key, "chckrcfg-")
parts := strings.SplitN(trimmed, "/", 4)
if len(parts) < 4 {
return trimmed, nil, nil, nil
}
checkerName = parts[0]
if parts[1] != "" {
if id, err := NewIdentifierFromString(parts[1]); err == nil {
userId = &id
}
}
if parts[2] != "" {
if id, err := NewIdentifierFromString(parts[2]); err == nil {
domainId = &id
}
}
if parts[3] != "" {
if id, err := NewIdentifierFromString(parts[3]); err == nil {
serviceId = &id
}
}
return
}

View file

@ -93,12 +93,20 @@ type Options struct {
OIDCClients []OIDCSettings
// CheckerMaxConcurrency is the maximum number of checker jobs that can
// run simultaneously. Defaults to runtime.NumCPU().
CheckerMaxConcurrency int
// CaptchaProvider selects the captcha provider ("hcaptcha", "recaptchav2", "turnstile", or "").
CaptchaProvider string
// CaptchaLoginThreshold is the number of consecutive login failures before captcha is required.
// 0 means always require captcha at login (when provider is configured).
CaptchaLoginThreshold int
// PluginsDirectories lists filesystem paths scanned at startup for
// checker plugins (.so files).
PluginsDirectories []string
}
// GetBaseURL returns the full url to the absolute ExternalURL, including BaseURL.

View file

@ -30,14 +30,14 @@ const (
CorrectionKindAddition
CorrectionKindUpdate
CorrectionKindDeletion
CorrectionKindOther = 99
CorrectionKindOther CorrectionKind = 99
)
type Correction struct {
F func() error `json:"-"`
Id Identifier `json:"id,omitempty" swaggertype:"string"`
Msg string `json:"msg"`
Kind CorrectionKind `json:"kind,omitempty"`
Msg string `json:"msg" binding:"required"`
Kind CorrectionKind `json:"kind" binding:"required"`
OldRecords []Record `json:"-"`
NewRecords []Record `json:"-"`
}

View file

@ -22,6 +22,7 @@
package happydns
import (
"context"
"errors"
"strings"
@ -41,24 +42,30 @@ type DomainCreationInput struct {
// Domain holds information about a domain name own by a User.
type Domain struct {
// Id is the Domain's identifier in the database.
Id Identifier `json:"id" swaggertype:"string"`
Id Identifier `json:"id" swaggertype:"string" binding:"required" readonly:"true"`
// Owner is the identifier of the Domain's Owner.
Owner Identifier `json:"id_owner" swaggertype:"string"`
Owner Identifier `json:"id_owner" swaggertype:"string" binding:"required"`
// ProviderId is the identifier of the Provider used to access and edit the
// Domain.
ProviderId Identifier `json:"id_provider" swaggertype:"string"`
ProviderId Identifier `json:"id_provider" swaggertype:"string" binding:"required"`
// DomainName is the FQDN of the managed Domain.
DomainName string `json:"domain"`
DomainName string `json:"domain" binding:"required"`
// Group is a hint string aims to group domains.
Group string `json:"group,omitempty"`
// ZoneHistory are the identifiers to the Zone attached to the current
// Domain.
ZoneHistory []Identifier `json:"zone_history" swaggertype:"array,string"`
ZoneHistory []Identifier `json:"zone_history" swaggertype:"array,string" binding:"required" readonly:"true"`
}
// DomainUpdateInput is used for swagger documentation as Domain update.
type DomainUpdateInput struct {
// Group is a hint string aims to group domains.
Group string `json:"group,omitempty"`
}
func NewDomain(user *User, name string, providerID Identifier) (*Domain, error) {
@ -101,7 +108,7 @@ type Subdomain string
type Origin string
type DomainUsecase interface {
CreateDomain(*User, *Domain) error
CreateDomain(context.Context, *User, *Domain) error
DeleteDomain(Identifier) error
ExtendsDomainWithZoneMeta(*Domain) (*DomainWithZoneMetadata, error)
GetUserDomain(*User, Identifier) (*Domain, error)

View file

@ -34,8 +34,13 @@ var (
ErrSessionNotFound = errors.New("session not found")
ErrUserNotFound = errors.New("user not found")
ErrUserAlreadyExist = errors.New("user already exists")
ErrZoneNotFound = errors.New("zone not found")
ErrNotFound = errors.New("not found")
ErrZoneNotFound = errors.New("zone not found")
ErrCheckerNotFound = errors.New("checker not found")
ErrCheckPlanNotFound = errors.New("check plan not found")
ErrCheckEvaluationNotFound = errors.New("check evaluation not found")
ErrExecutionNotFound = errors.New("execution not found")
ErrSnapshotNotFound = errors.New("snapshot not found")
ErrNotFound = errors.New("not found")
)
const TryAgainErr = "Sorry, we are currently unable to sent email validation link. Please try again later."

View file

@ -72,10 +72,10 @@ type CustomForm struct {
// Field
type Field struct {
// Id is the field identifier.
Id string `json:"id"`
Id string `json:"id" binding:"required"`
// Type is the string representation of the field's type.
Type string `json:"type"`
Type string `json:"type" binding:"required"`
// Label is the title given to the field, displayed as <label> tag on the interface.
Label string `json:"label,omitempty"`
@ -104,6 +104,10 @@ type Field struct {
// Description stores an helpfull sentence describing the field.
Description string `json:"description,omitempty"`
// AutoFill indicates that this field is automatically filled by the system
// based on execution context (e.g. domain name, zone, service type).
AutoFill string `json:"autoFill,omitempty"`
}
type FormState struct {

View file

@ -41,19 +41,19 @@ const (
type DomainLog struct {
// Id is the Log's identifier in the database.
Id Identifier `json:"id" swaggertype:"string"`
Id Identifier `json:"id" swaggertype:"string" binding:"required" readonly:"true"`
// IdUser is the identifier of the person responsible for the action.
IdUser Identifier `json:"id_user" swaggertype:"string"`
IdUser Identifier `json:"id_user" swaggertype:"string" binding:"required" readonly:"true"`
// Date is the date of the action.
Date time.Time `json:"date"`
Date time.Time `json:"date" binding:"required" format:"date-time" readonly:"true"`
// Content is the description of the action logged.
Content string `json:"content"`
Content string `json:"content" binding:"required" readonly:"true"`
// Level reports the criticity level of the action logged.
Level int8 `json:"level"`
Level int8 `json:"level" binding:"required" readonly:"true"`
}
type DomainLogWithDomainId struct {

View file

@ -34,10 +34,10 @@ type ProviderBody interface {
// ProviderInfos describes the purpose of a user usable provider.
type ProviderInfos struct {
// Name is the name displayed.
Name string `json:"name"`
Name string `json:"name" binding:"required"`
// Description is a brief description of what the provider is.
Description string `json:"description"`
Description string `json:"description" binding:"required"`
// Capabilites is a list of special ability of the provider (automatically filled).
Capabilities []string `json:"capabilities,omitempty"`
@ -72,13 +72,13 @@ type ProviderMinimal struct {
// ProviderMeta holds the metadata associated to a Provider.
type ProviderMeta struct {
// Type is the string representation of the Provider's type.
Type string `json:"_srctype"`
Type string `json:"_srctype" binding:"required"`
// Id is the Provider's identifier.
Id Identifier `json:"_id" swaggertype:"string"`
Id Identifier `json:"_id" swaggertype:"string" binding:"required" readonly:"true"`
// Owner is the User's identifier for the current Provider.
Owner Identifier `json:"_ownerid" swaggertype:"string"`
Owner Identifier `json:"_ownerid" swaggertype:"string" binding:"required" readonly:"true"`
// Comment is a string that helps user to distinguish the Provider.
Comment string `json:"_comment,omitempty"`
@ -124,18 +124,18 @@ func (p *Provider) Meta() *ProviderMeta {
}
type ProviderUsecase interface {
CreateProvider(*User, *ProviderMessage) (*Provider, error)
CreateDomainOnProvider(*Provider, string) error
DeleteProvider(*User, Identifier) error
GetUserProvider(*User, Identifier) (*Provider, error)
GetUserProviderMeta(*User, Identifier) (*ProviderMeta, error)
ListHostedDomains(*Provider) ([]string, error)
ListUserProviders(*User) ([]*ProviderMeta, error)
ListZoneCorrections(ctx context.Context, provider *Provider, domain *Domain, records []Record) ([]*Correction, int, error)
CreateProvider(context.Context, *User, *ProviderMessage) (*Provider, error)
CreateDomainOnProvider(context.Context, *Provider, string) error
DeleteProvider(context.Context, *User, Identifier) error
GetUserProvider(context.Context, *User, Identifier) (*Provider, error)
GetUserProviderMeta(context.Context, *User, Identifier) (*ProviderMeta, error)
ListHostedDomains(context.Context, *Provider) ([]string, error)
ListUserProviders(context.Context, *User) ([]*ProviderMeta, error)
ListZoneCorrections(context.Context, *Provider, *Domain, []Record) ([]*Correction, int, error)
RetrieveZone(context.Context, *Provider, string) ([]Record, error)
TestDomainExistence(*Provider, string) error
UpdateProvider(Identifier, *User, func(*Provider)) error
UpdateProviderFromMessage(Identifier, *User, *ProviderMessage) error
TestDomainExistence(context.Context, *Provider, string) error
UpdateProvider(context.Context, Identifier, *User, func(*Provider)) error
UpdateProviderFromMessage(context.Context, Identifier, *User, *ProviderMessage) error
}
type ProviderActuator interface {

View file

@ -21,7 +21,7 @@
package happydns
import ()
import "context"
type ProviderSettingsState struct {
FormState
@ -35,5 +35,5 @@ type ProviderSettingsResponse struct {
}
type ProviderSettingsUsecase interface {
NextProviderSettingsState(*ProviderSettingsState, string, *User) (*Provider, *ProviderSettingsResponse, error)
NextProviderSettingsState(context.Context, *ProviderSettingsState, string, *User) (*Provider, *ProviderSettingsResponse, error)
}

View file

@ -40,30 +40,48 @@ type ResolverRequest struct {
Type string `json:"type"`
}
// DNSQuestion holds a single DNS question entry.
type DNSQuestion struct {
// Name is the domain name researched.
Name string
Name string `json:"name"`
// Qtype is the type of record researched.
Qtype uint16
Qtype uint16 `json:"qtype"`
// Qclass is the class of record researched.
Qclass uint16
Qclass uint16 `json:"qclass"`
}
// DNSMsg is the documentation struct corresponding to dns.Msg
type DNSMsg struct {
// ResolverResponse is the API response for a DNS resolution.
type ResolverResponse struct {
// Question is the Question section of the DNS response.
Question []DNSQuestion
Question []DNSQuestion `json:"question"`
// Answer is the list of Answer records in the DNS response.
Answer []any `swaggertype:"object"`
Answer []dns.RR `json:"answer" swaggertype:"object"`
// Ns is the list of Authoritative records in the DNS response.
Ns []any `swaggertype:"object"`
Ns []dns.RR `json:"ns" swaggertype:"object"`
// Extra is the list of extra records in the DNS response.
Extra []any `swaggertype:"object"`
Extra []dns.RR `json:"extra" swaggertype:"object"`
}
// NewResolverResponseFromMsg converts a dns.Msg to a ResolverResponse.
func NewResolverResponseFromMsg(msg *dns.Msg) *ResolverResponse {
resp := &ResolverResponse{
Answer: msg.Answer,
Ns: msg.Ns,
Extra: msg.Extra,
}
for _, q := range msg.Question {
resp.Question = append(resp.Question, DNSQuestion{
Name: q.Name,
Qtype: q.Qtype,
Qclass: q.Qclass,
})
}
return resp
}
type ResolverUsecase interface {

View file

@ -23,6 +23,7 @@ package happydns
import (
"encoding/json"
"time"
)
type Service struct {
@ -83,19 +84,19 @@ type SPFContributor interface {
// ServiceMeta holds the metadata associated to a Service.
type ServiceMeta struct {
// Type is the string representation of the Service's type.
Type string `json:"_svctype"`
Type string `json:"_svctype" binding:"required" readonly:"true"`
// Id is the Service's identifier.
Id Identifier `json:"_id,omitempty" swaggertype:"string"`
Id Identifier `json:"_id,omitempty" swaggertype:"string" readonly:"true"`
// OwnerId is the User's identifier for the current Service.
OwnerId Identifier `json:"_ownerid,omitempty" swaggertype:"string"`
// Domain contains the abstract domain where this Service relates.
Domain string `json:"_domain"`
Domain string `json:"_domain" binding:"required"`
// Ttl contains the specific TTL for the underlying Resources.
Ttl uint32 `json:"_ttl"`
Ttl uint32 `json:"_ttl" binding:"required"`
// Comment is a string that helps user to distinguish the Service.
Comment string `json:"_comment,omitempty"`
@ -108,7 +109,11 @@ type ServiceMeta struct {
Aliases []string `json:"_aliases,omitempty"`
// NbResources holds the number of Resources stored inside this Service.
NbResources int `json:"_tmp_hint_nb"`
NbResources int `json:"_tmp_hint_nb" binding:"required"`
// PropagatedAt is the estimated time at which the last published changes
// for this service will be fully propagated (old cached records expired).
PropagatedAt *time.Time `json:"_propagated_at,omitempty" format:"date-time"`
}
// ServiceCombined combined ServiceMeta + Service

View file

@ -53,13 +53,13 @@ type ServiceRestrictions struct {
}
type ServiceInfos struct {
Name string `json:"name"`
Type string `json:"_svctype"`
Name string `json:"name" binding:"required"`
Type string `json:"_svctype" binding:"required"`
Icon string `json:"_svcicon,omitempty"`
Description string `json:"description"`
Family string `json:"family"`
Categories []string `json:"categories"`
RecordTypes []uint16 `json:"record_types"`
Description string `json:"description" binding:"required"`
Family string `json:"family" binding:"required"`
Categories []string `json:"categories" binding:"required"`
RecordTypes []uint16 `json:"record_types" binding:"required"`
Tabs bool `json:"tabs,omitempty"`
Restrictions ServiceRestrictions `json:"restrictions,omitempty"`
}

View file

@ -25,30 +25,39 @@ import (
"time"
)
// Session holds informatin about a User's currently connected.
// Session holds information about a User's currently connected.
type Session struct {
// Id is the Session's identifier.
Id string `json:"id"`
Id string `json:"id" binding:"required" readonly:"true"`
// IdUser is the User's identifier of the Session.
IdUser Identifier `json:"login" swaggertype:"string"`
IdUser Identifier `json:"login" swaggertype:"string" binding:"required" readonly:"true"`
// Description is a user defined string aims to identify each session.
Description string `json:"description"`
Description string `json:"description" binding:"required"`
// IssuedAt holds the creation date of the Session.
IssuedAt time.Time `json:"time"`
IssuedAt time.Time `json:"time" binding:"required" format:"date-time" readonly:"true"`
// ExpiresOn holds the expirate date of the Session.
ExpiresOn time.Time `json:"exp"`
ExpiresOn time.Time `json:"exp" binding:"required" format:"date-time"`
// ModifiedOn is the last time the session has been updated.
ModifiedOn time.Time `json:"upd"`
ModifiedOn time.Time `json:"upd" binding:"required" format:"date-time"`
// Content stores data filled by other modules.
Content string `json:"content,omitempty"`
}
// SessionInput is used for creating or updating a session.
type SessionInput struct {
// Description is a user defined string aims to identify each session.
Description string `json:"description"`
// ExpiresOn holds the expirate date of the Session.
ExpiresOn time.Time `json:"exp" format:"date-time"`
}
// ClearSession removes all content from the Session.
func (s *Session) ClearSession() {
s.Content = ""

View file

@ -29,19 +29,19 @@ import (
// User represents an account.
type User struct {
// Id is the User's identifier.
Id Identifier `json:"id" swaggertype:"string"`
Id Identifier `json:"id" swaggertype:"string" binding:"required" readonly:"true"`
// Email is the User's login and means of contact.
Email string `json:"email"`
Email string `json:"email" binding:"required"`
// CreatedAt is the time when the User logs in for the first time.
CreatedAt time.Time `json:"created_at,omitempty"`
CreatedAt time.Time `json:"created_at" format:"date-time" binding:"required" readonly:"true"`
// LastSeen is the time when the User used happyDNS for the last time (in a 12h frame).
LastSeen time.Time `json:"last_seen,omitempty"`
LastSeen time.Time `json:"last_seen" format:"date-time" binding:"required" readonly:"true"`
// Settings holds the settings for an account.
Settings UserSettings `json:"settings,omitempty"`
Settings UserSettings `json:"settings" binding:"required"`
}
func (u *User) GetUserId() Identifier {

View file

@ -29,7 +29,7 @@ import (
type UserProfile struct {
UserId []byte `json:"userid"`
Name string `json:"username"`
CreatedAt time.Time `json:"created_at"`
CreatedAt time.Time `json:"created_at" format:"date-time"`
}
func (u *UserProfile) GetUserId() Identifier {

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