Compare commits

...

110 commits

Author SHA1 Message Date
365d608d78 Load service plugins
Some checks failed
continuous-integration/drone/push Build is failing
2026-03-16 23:13:20 +07:00
973e659a24 Load provider plugins 2026-03-16 23:13:20 +07:00
4ed76a8a11 Load checks plugins 2026-03-16 23:08:55 +07:00
c384c10a88 New custom flag parser: ArrayArgs 2026-03-16 23:08:55 +07:00
f16ae2991e fix: refresh ButtonZonePublish after zone apply
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-16 21:00:35 +07:00
72fa2b3904 fix: wire ActionOnEditableZone into all zone mutation facades
All checks were successful
continuous-integration/drone/push Build is passing
Add/update/delete service calls in the Service facade were bypassing
ActionOnEditableZone, so mutations could silently target a committed or
published zone instead of deriving a new editable snapshot first.

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

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

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

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

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

Introduce SPFContributor interface so services can declare SPF
directives independently. At zone generation time, all contributions
for the same domain are merged into a single SPF record with the
strictest "all" policy winning. During zone import, GSuite claims its
directive via ClaimSPFDirective so the SPF analyzer excludes it from
the standalone SPF service.
2026-03-14 10:36:53 +07:00
fff3c29876 docs: add AI disclaimer section to README
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-12 17:47:12 +07:00
f96f894168 CI: github_url is required to publish a release 2026-03-12 15:59:27 +07:00
e73b6df40a ci: add Codeberg, GitHub and local forgejo release publishing steps on tag
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is failing
2026-03-12 15:03:56 +07:00
d4970a109d fix: defer OIDC session key deletion until successful authentication
Previously the CSRF state, PKCE verifier, nonce, and next-path were
deleted and the session saved before the token exchange. A failure during
exchange or verification left the user with no way to retry without
restarting the whole flow.

Remove the intermediate session.Save(): the in-memory deletions are
discarded on any error so the session keys remain available for a retry.
On success, SessionLoginOK calls session.Clear() + Save() which atomically
consumes all keys. PKCE ensures the authorization code cannot be replayed
independently of the session.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 15:03:56 +07:00
b4ad9f8092 fix: preserve post-login redirect destination through OIDC flow
The next query parameter was silently dropped when users chose OIDC
login, always redirecting to / after authentication. Forward the
validated next value to /auth/oidc, store it in the session during
redirect, and use it for the final redirect in the callback, matching
the behaviour of password-based login.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 15:03:56 +07:00
4bab6644b0 security: add nonce validation to OIDC flow to prevent ID token replay
Generate a cryptographically random nonce at redirect time, store it in
the session, and include it in the authorization request. After token
verification, reject the callback if the ID token's nonce claim does not
match the session value, preventing replayed or stolen ID tokens from
being accepted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 15:03:56 +07:00
c6a2a8cea3 security: add PKCE (S256) to OIDC authorization code flow
Generate a cryptographic code verifier at redirect time, store it in the
session, and send the S256 code_challenge in the authorization request.
Use the verifier during token exchange to bind the code to the session
that initiated the flow, protecting against authorization code
interception attacks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 15:03:56 +07:00
8e90d7be94 security: redact internal OIDC error details from HTTP responses
Log the underlying error server-side and return a generic message to
the client, preventing information leakage of library internals, error
details, and internal URLs through the OIDC callback error responses.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 15:03:56 +07:00
cae068c4e9 security: replace SHA-1 with SHA-256 for OIDC user ID derivation
SHA-1 has known collision vulnerabilities. Switch to SHA-256 when
deriving a deterministic user identifier from the email address in the
OIDC callback, eliminating the risk of crafted email collision attacks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 15:03:56 +07:00
d979ccefe6 security: validate next redirect parameter to prevent open redirect
Decode and validate the next query parameter before navigating,
ensuring it is a same-origin relative path (starts with / but not //)
to prevent attackers from redirecting users to external sites after login.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 15:03:56 +07:00
68a783b7bb security: 15-day session lifetime with 7-day auto-renewal
- Reduce SESSION_MAX_DURATION from 365 days to 15 days
- Add SESSION_RENEWAL_THRESHOLD (7 days): sessions are only extended
  when fewer than 7 days remain, instead of refreshing on every request
- Align cookie MaxAge with SESSION_MAX_DURATION (derived from the constant)
- Enforce expiry in load(): expired sessions are deleted on first use
  and the caller receives an error, preventing Bearer-token replay of
  stale sessions that the securecookie age check would not catch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 15:03:56 +07:00
ca206cf24e fix: make updateSession reject calls without an id
The function silently fell back to creating a new session when session.id
was falsy, which could create unintended API tokens from a partial object.
Session creation is already handled by addSession(); updateSession() now
throws early when no id is present.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 15:03:49 +07:00
28ac875585 fix: return updated session from UpdateSession endpoint
The handler fetched the session before applying the update and returned
that pre-update snapshot. The client therefore never saw the new
Description or ExpiresOn values. Fetch the session after the update
so the response reflects the persisted state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 15:03:49 +07:00
c682e463d3 fix: set session.IsNew=false only on successful load
Previously IsNew was unconditionally set to false after s.load() even
when load returned an error. Callers that branch on IsNew could treat
a broken/missing session as a pre-existing authenticated one.

Only mark the session as not-new when the load actually succeeded.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 15:03:49 +07:00
f9d66bf53e security: validate session ID format from Authorization header
Bearer tokens and Basic Auth usernames were used as session IDs without
any format validation, allowing arbitrary strings (including crafted or
very long values) to reach the storage layer as untrusted session IDs.

Restrict accepted session IDs to the exact format produced by
NewSessionID(): standard base32 alphabet [A-Z2-7], exactly 103 chars.
Any token that does not match is ignored, resulting in a new anonymous
session instead of a storage lookup with attacker-controlled input.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 15:03:49 +07:00
8ab02dffa8 security: propagate DeleteSession error in Save when MaxAge < 0
Previously, if session deletion failed (e.g. storage error), the error
was silently swallowed. The stale session could still be replayed via
Bearer token even after the client-side cookie was cleared.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 15:03:49 +07:00
90f07a215c security: rotate session ID on login to prevent session fixation
The server-side session store (gorilla/sessions backed by DB) reused the
same session ID across login: session.Clear() only zeroed the values map
but left session.ID unchanged. An attacker who planted a known session ID
before authentication retained access after the victim logged in.

Fix with a two-phase save:
1. Delete the old session from the DB (MaxAge=-1 save), expiring the cookie.
2. Reset the underlying gorilla Session.ID to "" so the store generates a
   fresh ID, then save the authenticated session with original cookie options
   (Secure, Path, MaxAge) preserved via a duck-typed interface assertion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 15:03:49 +07:00
b0b79efceb security: decouple failure tracking from captcha provider
Previously, RecordFailure/RecordSuccess were only called when a captcha
provider was configured, making brute-force tracking entirely inactive
on deployments without one.

- Always track login failures and successes regardless of captcha config
- When threshold is crossed with a captcha provider: 401 + captcha_required (existing behaviour)
- When threshold is crossed without a captcha provider: 429 + rate_limited flag
- Frontend: show a rate-limited message and disable the submit button on 429
- Add errors.rate-limited translation key to all locales

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 15:03:49 +07:00
d6e442f02b security: prevent email enumeration via timing side-channel
When the requested email does not exist, the function returned in
microseconds, while a valid email with wrong password took ~100ms
(bcrypt). An attacker could enumerate valid accounts by measuring
response latency.

Add a dummy bcrypt.CompareHashAndPassword call on the not-found path so
both branches take a comparable amount of time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 15:03:49 +07:00
a2c060639a security: remove email PII from error messages, log at HTTP boundary
Email addresses embedded in error strings could leak to arbitrary log
sinks or error responses as errors propagate. Strip them from the usecase
errors and instead log `IP email: reason` once at the controller level,
keeping fail2ban/CrowdSec-compatible log lines.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 15:03:49 +07:00
c16e9c243f Transparently rehash passwords with outdated bcrypt cost on login
Add a bcryptCost constant to centralize the target cost (12), a
NeedsRehash() method that checks the stored hash cost via bcrypt.Cost(),
and trigger a transparent rehash in AuthenticateUserWithPassword when
the stored hash is below the current target.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 13:06:20 +07:00
46a5d15aa4 security: increase bcrypt cost from default (10) to 12
OWASP now recommends bcrypt cost >= 12. Using the implicit default cost
of 0 (which maps to 10) is below current recommendations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 13:06:20 +07:00
043b81a350 security: enforce 72-character maximum password length
bcrypt silently truncates input at 72 bytes. Without an explicit maximum,
a user could set a 200-char password and log in with only the first 72
chars, and very long passwords could be used for a CPU-based DoS.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 13:06:20 +07:00
a5601451cf fix(deps): update module golang.org/x/crypto to v0.49.0
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-11 23:12:39 +00:00
ce31eb09c4 fix(deps): update module golang.org/x/oauth2 to v0.36.0
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-11 13:13:14 +00:00
4f5f8b0ee6 chore(deps): update module github.com/happydomain/dnscontrol/v4 to v4.36.100
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-11 18:30:41 +07:00
0b1c5c789d chore(deps): update dependency go to v1.26.1 2026-03-11 18:30:41 +07:00
dcde50f56a web: Highlight current session with a Badge 2026-03-11 18:30:41 +07:00
6565b25473 fix: use first 6 bytes of SHA-256 for session fingerprint display
SHA-1 is cryptographically broken. Replace with SHA-256 and slice to
the first 6 bytes (12 hex chars) for a compact, human-readable token
fingerprint. 48 bits is more than sufficient to distinguish a handful
of active sessions without sacrificing readability.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 18:30:41 +07:00
378227d708 fix: return 204 No Content on session delete
DeleteSession was returning 200 with a null body instead of the
semantically correct 204 No Content. Updated the Swagger annotation
to match the new status code.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 18:30:41 +07:00
36890cc432 web: Fix infinite loop on 401 when user is not logged in 2026-03-11 18:30:41 +07:00
d99e31d587 web: Increase margin under root domain name 2026-03-11 18:30:41 +07:00
a60489d0cf fix: update LastLoggedIn on successful password authentication
The field existed but was never written, making it useless for security
auditing. Record the time at each successful login and persist it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 18:30:41 +07:00
8cf643131d web: fix ServiceCombined JSON serialization for new service creation 2026-03-11 18:30:41 +07:00
54857e19c6 web: cache zone diff API results to avoid redundant network calls 2026-03-06 14:43:09 +07:00
7b8e6600fe web: replace /domains/new navigation with PickProvider modal
Introduces ProviderPicker and PickProvider reusable components so that
adding a domain from NewDomainInput or FilterDomainInput opens an
inline provider-selection modal instead of navigating away to
/domains/new/:dn.
2026-03-06 14:43:09 +07:00
e8af55a989 web: display onboarding form on home page with ?onboarding 2026-03-06 14:43:09 +07:00
03be1f7348 web: replace domains home page with an interactive table 2026-03-06 14:43:09 +07:00
0677b82dfc web: relook provider edit page and add provider sidebar 2026-03-06 14:43:09 +07:00
5f6b9a22b9 web: sync home filter state with URL query params 2026-03-06 14:43:02 +07:00
2e7713fec0 web: Format files and remove unused dependencies 2026-03-06 12:44:15 +07:00
827a92e77e web: replace providers list with an interactive table
Replace the ListGroup-based provider list with a Bootstrap Table on the
providers page. Rows are clickable to edit, the domain count links to
the domains page pre-filtered by provider, and action buttons handle
propagation correctly.
2026-03-06 12:44:15 +07:00
6a00090d0c web: replace providers/new page with NewProvider modal
Replace the dedicated provider type selection page with a modal,
using a module-level controller pattern. The /providers/new route
now redirects to /providers?newProvider, which auto-opens the modal.
2026-03-06 12:44:15 +07:00
6360938660 README: Include Matrix badge 2026-03-06 12:44:15 +07:00
77f9dde4bf web: add PageTitle component and apply it across all pages
Introduces a reusable PageTitle component with a teal overline accent,
display-3 heading, optional monospace domain label, subtitle, and a
children slot for future domain health/check badges. Applied consistently
to the zone viewer, history, logs, export, import, resolver, providers,
account settings, and new-domain pages.
2026-03-06 12:44:15 +07:00
5ece0f15ca web: document all functions in dns.ts with JSDoc 2026-03-06 12:44:15 +07:00
ba29d13a17 web: add service details offcanvas with DNS records and actions
Introduce ServiceDetailsOffcanvas, an offcanvas panel that opens when
clicking a service card. It displays the service description, its DNS
records, and provides actions buttons.

Also remove raw DNS record from service form.
2026-03-06 12:44:15 +07:00
efebd7e4e2 web: add DNS syntax highlighting with highlight.js
Install highlight.js and apply DNS zone file syntax highlighting on the
export page and in the RecordText component. Uses the github theme and
imports only the dns language to keep the bundle small.
2026-03-05 16:25:44 +07:00
044c6da31a web: replace ModalViewZone with a dedicated export page
Convert the zone file viewer from a modal dialog to a dedicated page at
/domains/[dn]/export, following the same pattern used for service pages.
Adds a "Copy to clipboard" button in the page title bar and adds the
common.copy-clipboard translation key to all supported locales.
2026-03-05 16:25:44 +07:00
91c431f23c web: Improve abstract view 2026-03-05 16:25:44 +07:00
35ea32dcea web: centralize service help link logic in HelpButton component
Introduce a helpLinkOverride store so the Header's help button can
display context-sensitive service docs. Move the svctype-to-URL
computation into Help.svelte (service prop + $effect), removing the
duplicated helpLink functions from the service edit page and the modal
Footer. Pages now render <HelpButton {service} /> to drive the override
without showing a redundant per-page button.
2026-03-05 16:25:44 +07:00
acf7c0d152 web: replace service modal with dedicated page and sidebar
Replace the Service modal component with a dedicated service page route
and a ServiceSidebar component, improving navigation by giving each
service its own URL under [subdomain]/[serviceid].
2026-03-05 16:25:44 +07:00
633a3d6c72 web: focus newly added SPF directive input on creation 2026-03-04 02:24:38 +07:00
36bf664eaa web: sync sidebar scroll with visible subdomain in main content
Use IntersectionObserver to track which subdomain section is currently
visible in the top 30% of the viewport, bold the matching sidebar link,
and auto-scroll the sidebar to keep it in view with scroll-margin-block
so adjacent items remain visible.
2026-03-04 02:24:38 +07:00
7217b6ab18 web: improve subdomain list styling with domain part highlighting
Split subdomain display to show the subdomain and domain parts
separately, making the root domain bold and dimming the domain
suffix. Add hover bold effect for text-dark links.
2026-03-04 02:24:38 +07:00
eb5e0adc0f web: extract zone sidebar into dedicated ZoneSidebar component
Refactor the domain layout by moving the zone-specific sidebar content
(subdomain list, zone actions dropdown) into a new ZoneSidebar.svelte
component, improving separation of concerns between zone and service views.
2026-03-04 02:24:38 +07:00
0782f99c19 docs: improve README with badges, ToC, contributing and license sections
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-02 10:35:58 +07:00
d588ade59d web: replace deprecated cuid with @paralleldrive/cuid2
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-02 10:08:27 +07:00
ec51c095d8 chore(deps): lock file maintenance
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-02 01:18:49 +00:00
e8a6f2bdbd web: Add transition to VoxPeople card and fix URL param
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-01 17:41:02 +07:00
f0bf1b0b62 web: Integrate BasePath support into frontend and fix web route serving 2026-03-01 17:41:02 +07:00
64c86df9ac Support configurable BasePath for hosting at a sub-path 2026-03-01 17:41:01 +07:00
bf34051069 Reformat manifest.json, add id and fix spelling 2026-03-01 17:40:20 +07:00
ac441a0a25 web: fix service worker caching bugs
- Add network fallback for asset cache misses (prevents broken requests
  on install race conditions)
- Fix query string stripping to use a clean Request instead of copying
  event.request options
- Await cache.put() calls to prevent incomplete writes on SW termination
- Expand auth path exclusion to startsWith("/api/auth") to cover all
  auth-related endpoints
2026-03-01 17:40:20 +07:00
d664bee36d web: Fix coding-style issues 2026-03-01 17:40:20 +07:00
ee0e22adf5 chore: apply Go naming conventions and minor code improvements 2026-03-01 17:38:12 +07:00
f457071d5d chore: replace interface{} with any across the codebase
Use the modern Go type alias `any` instead of `interface{}` throughout
all packages for improved readability and consistency with current Go idioms.
2026-03-01 17:38:04 +07:00
35843f740c chore: modernize Go idioms in config and forms packages 2026-03-01 17:38:01 +07:00
001b919870 fix: resolve go vet variable shadowing warnings 2026-03-01 17:37:59 +07:00
759fcf5cee chore: replace deprecated ioutil functions with io and os equivalents 2026-03-01 16:40:34 +07:00
15298c4101 chore: remove deprecated rand.Seed call and unused imports 2026-03-01 16:38:52 +07:00
28d90fd8d0 fix(deps): update module github.com/gin-gonic/gin to v1.12.0
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-28 11:18:51 +00:00
464c7db123 web: insert new SPF directive before -all even when it's the only entry
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-27 00:18:50 +07:00
51d993d14b chore(deps): lock file maintenance
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-26 09:26:58 +07:00
323389d13a Bump dnscontrol to v4.35.0 + add MikroTik and UniFi providers, drop Hexonet 2026-02-25 22:18:32 +07:00
e90a561b4b Update go packages + bump dnscontrol to 4.34.0 2026-02-21 23:23:14 +07:00
exyone
38b5364823 Improve Chinese localization and add simplified Chinese README
All checks were successful
continuous-integration/drone/push Build is passing
- Refine zh.json translations for natural Chinese expressions
- Remove machine translation artifacts while preserving technical accuracy
- Add README.zh-cn.md for Chinese-speaking users
- Improve tone with subtle classical Chinese elements
2026-02-16 14:03:50 +08:00
31c391bbf3 chore(deps): lock file maintenance
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-16 01:18:15 +00:00
354 changed files with 12228 additions and 4268 deletions

View file

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

View file

@ -3,21 +3,42 @@ happyDomain
happyDomain is a free web application that centralizes the management of your domain names from different registrars and hosts.
[![License: AGPL v3](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](./LICENSE)
[![Docker Hub](https://img.shields.io/docker/pulls/happydomain/happydomain)](https://hub.docker.com/r/happydomain/happydomain)
[![Build Status](https://drone.nemunai.re/api/badges/happyDomain/happyDomain/status.svg)](https://drone.nemunai.re/happyDomain/happyDomain)
[![Matrix](https://img.shields.io/badge/matrix-%23000000?style=for-the-badge&logo=matrix&logoColor=white)](https://matrix.to/#/%23happyDNS:matrix.org)
[![Feedback](https://img.shields.io/badge/feedback-welcome-brightgreen)](https://feedback.happydomain.org/)
![Screenshots of happyDomain](./docs/header.webp)
It consists of a HTTP REST API written in Golang (primarily based on https://stackexchange.github.io/dnscontrol/ and https://github.com/miekg/dns) with a nice web interface written with [Svelte](https://svelte.dev/).
It runs as a single stateless Linux binary, backed by a database (currently: LevelDB, more to come soon).
It consists of a HTTP REST API written in Golang (primarily based on https://dnscontrol.org/ and https://github.com/miekg/dns) with a nice web interface written with [Svelte](https://svelte.dev/).
It runs as a single stateless Linux binary, backed by a database.
**Features:**
Table of Contents
-----------------
- [Features](#features)
- [Getting Started With Docker](#getting-started-with-docker)
- [Install from binary](#install-from-binary)
- [Configuration](#use-happydomain)
- [Building from source](#building)
- [Development environment](#development-environment)
- [Contributing](#contributing)
- [License](#license)
Features
--------
* An ultra fast web interface without compromise
* Multiple domains management
* Support for 44+ DNS providers (including dynamic DNS, RFC 2136) thanks to [DNSControl](https://stackexchange.github.io/dnscontrol/)
* Support for 60+ DNS providers (including dynamic DNS, RFC 2136) thanks to [DNSControl](https://dnscontrol.org/)
* Support for the most recents resource records thanks to [CoreDNS's library](https://github.com/miekg/dns)
* Zone editor with a diff view to review the changes before propagation
* Keep an history of published changes
* Contextual help
* Multiple users with authentication or one user without authtication
* Multiple users with authentication or one user without authentication
* Compatible with external authentication (OpenId Connect or through JWT tokens: Auth0, ...)
**happyDomain is functional but still very much a work in progress: it's a carefully crafted proof of concept that evolves thanks to you!**
@ -27,8 +48,8 @@ Given the diversity of DNS configurations and user needs, we haven't yet identif
[Whether it works for you or not, we need your feedback!](https://feedback.happydomain.org/) What do you think of our approach to simplifying domain name management? Your impressions at this stage help us guide the project according to **your actual expectations**.
Using Docker
------------
Getting Started With Docker
---------------------------
We are a Docker sponsored OSS project! Thus you can easily try and/or deploy our app using Docker/podman/kubernetes/...
@ -258,3 +279,51 @@ cd web; npm run dev
```
With this setup, static assets integrated inside the go binary will not be used, instead it'll forward all requests for static assets to the node server, that do dynamic reload, etc.
Contributing
------------
Contributions are welcome! Here's how you can help:
- **Report bugs:** Open an issue on your favorite forge: [GitHub](https://github.com/happyDomain/happydomain/issues), [Gitlab](https://gitlab.com/happyDomain/happydomain/-/issues), [Framagit](https://framagit.org/happyDomain/happydomain/-/issues), [Codeberg](https://codeberg.org/happyDomain/happyDomain/issues), we're highly responsive.
- **Share feedback:** [Tell us what you think](https://feedback.happydomain.org/), your input guides the project.
AI Disclaimer
-------------
There have been questions about AI usage in project development. Our project handles domain name management, a sensitive area where mistakes can cause real outages, it's important to explain how AI is used in the development process.
AI is used as a helper for:
- verification of code quality and searching for vulnerabilities
- cleaning up and improving documentation, comments and code
- assistance during development
- double-checking PRs and commits after human review
AI is not used for:
- writing entire features or components
- "vibe coding" approach
- code without line-by-line verification by a human
- code without tests
The project has:
- CI/CD pipeline automation with tests and linting to ensure code quality
- verification by experienced developers
So AI is just an assistant and a tool for developers to increase productivity and ensure code quality. The work is done by developers.
We do not differentiate between bad human code and AI vibe code. There are strict requirements for any code to be merged to keep the codebase maintainable. Even if code is written manually by a human, it's not guaranteed to be merged. Vibe code is not allowed and such PRs are rejected.
*Inspired by the [Databasus AI disclaimer](https://github.com/databasus/databasus#ai-disclaimer).*
License
-------
happyDomain is licensed under the [GNU Affero General Public License v3.0](./LICENSE) (AGPL-3.0).
A commercial license is also available, contact us if interested.

264
README.zh-cn.md Normal file
View file

@ -0,0 +1,264 @@
happyDomain
===========
> 中文译者:[Exyone](https://www.exyone.me/)
由于软件架构限制,当前 happyDomain 仅支持简体中文,繁体中文用户请安装浏览器拓展插件以进行简繁转换。造成困扰,敬请谅解。
happyDomain 是一款免费的 Web 应用,可集中管理来自不同注册商和托管商的域名。
![happyDomain 截图](./docs/header.webp)
它由 Golang 编写的 HTTP REST API主要基于 https://stackexchange.github.io/dnscontrol/ 和 https://github.com/miekg/dns与 [Svelte](https://svelte.dev/) 构建的精美 Web 界面组成。
作为单一无状态的 Linux 二进制文件运行,支持多种数据库(当前支持 LevelDB更多选项即将推出
**主要特性:**
* 高性能 Web 界面,响应迅速
* 支持多域名管理
* 支持 60+ DNS 提供商(含动态 DNS、RFC 2136得益于 [DNSControl](https://stackexchange.github.io/dnscontrol/)
* 支持最新资源记录类型,得益于 [CoreDNS 库](https://github.com/miekg/dns)
* 区域编辑器支持差异对比,部署前轻松审查变更
* 保留部署变更历史记录
* 上下文帮助
* 支持多用户认证或单用户无认证模式
* 兼容外部认证OpenId Connect 或 JWT 令牌Auth0 等)
**happyDomain 已可投入使用,但仍需不断完善:这是一个精心打造的概念验证版本,您的反馈将助力其不断进化!**
鉴于 DNS 配置和用户需求的多样性,我们尚未发现所有潜在问题。**若遇问题,请勿离去:[向我们反馈问题所在](https://github.com/happyDomain/happydomain/issues)。** 我们响应迅速,每个报告的 bug 都能帮助改进工具,惠及众人。
[无论使用体验如何,我们都期待您的反馈!](https://feedback.happydomain.org/) 您如何看待我们简化域名管理的方式?您的初步印象有助于我们根据**您的实际期望**来指引项目方向。
使用 Docker
------------
我们是由 Docker 赞助的开源项目!因此您可以轻松使用 Docker/podman/kubernetes/... 来试用或部署应用。
使用 `docker compose` 启动 happyDomain
```bash
git clone https://framagit.org/happyDomain/happyDomain.git
cd happyDomain
docker compose up
```
或直接使用 `docker run`
```bash
docker run -e HAPPYDOMAIN_NO_AUTH=1 -p 8081:8081 happydomain/happydomain
```
此命令将在数秒内启动 happyDomain用于评估测试无认证、临时存储等。使用浏览器访问 <http://localhost:8081> 即可体验!
部署 happyDomain请查阅 [Docker 镜像文档](https://hub.docker.com/r/happydomain/happydomain)。
从二进制文件安装
-------------------
预编译二进制文件下载地址:<https://get.happydomain.org/>
选择目录(最新版本或 master 分支),然后选择与您的操作系统和 CPU 架构对应的二进制文件。
使用 happyDomain
---------------
二进制文件附带默认配置,可直接启动。在终端中运行以下命令即可:
```bash
./happyDomain
```
初始化完成后,应显示以下信息:
Admin listening on ./happydomain.sock
Ready, listening on :8081
访问 http://localhost:8081/ 开始使用 happyDomain。
### 数据库配置
默认使用 LevelDB 存储引擎。可使用 `-storage-engine` 选项更改存储引擎。
运行 `./happyDomain -help` 查看可用存储引擎:
```
-storage-engine value
在 [inmemory leveldb oracle-nosql postgresql] 中选择存储引擎 (默认 leveldb)
```
#### LevelDB
LevelDB 是轻量级嵌入式键值存储(类似 SQLite无需额外守护进程
```
-leveldb-path string
LevelDB 数据库路径 (默认 "happydomain.db")
```
默认在二进制文件所在目录创建 `happydomain.db` 目录。可更改为更有意义或更持久的路径。
#### inmemory
数据存储于内存中,服务停止后数据即丢失。
#### PostgreSQL
PostgreSQL 支持主要面向已部署 PostgreSQL 数据库基础设施的环境。这允许您利用现有数据库设置、备份流程和运维工具,无需部署额外数据库系统。
happyDomain 以键值存储模式使用 PostgreSQL将所有数据存储在包含 `key``value` 列的单张表中。虽然可行但请注意与专用键值存储相比PostgreSQL 并非键值工作负载的最佳选择。若从头部署且需超出 LevelDB 的可扩展性,请考虑使用专为键值操作设计的存储后端。
```
-postgres-database string
PostgreSQL 数据库名称 (默认 "happydomain")
-postgres-host string
PostgreSQL 服务器主机名 (默认 "localhost")
-postgres-password string
PostgreSQL 密码
-postgres-port int
PostgreSQL 服务器端口 (默认 5432)
-postgres-ssl-mode string
PostgreSQL SSL 模式 (disable, require, verify-ca, verify-full) (默认 "disable")
-postgres-table string
键值存储的 PostgreSQL 表名 (默认 "happydomain_kv")
-postgres-user string
PostgreSQL 用户名 (默认 "happydomain")
```
#### Oracle NoSQL Database
Oracle NoSQL Database 是来自 Oracle Cloud Infrastructure (OCI) 的全托管云服务提供按需吞吐量和高可用的存储配置。happyDomain 可将其作为可扩展的云端存储后端用于生产部署。
使用 Oracle NoSQL Database 需拥有 OCI 账户并创建 NoSQL 表。表需包含主键字段 `key`(字符串类型)和 `value` 字段JSON 类型)存储数据。认证使用 OCI 的 IAM 和 API 签名密钥。
配置以下选项连接 happyDomain 至 Oracle NoSQL Database
```
-oci-compartment string
NoSQL 数据库所在的 OCI 隔间 ID
-oci-fingerprint string
OCI 用户 API 密钥指纹
-oci-private-key-file string
给定用户的 OCI 私钥文件路径
-oci-region string
NoSQL 数据库所在的 OCI 区域 (默认 "us-phoenix-1")
-oci-table string
存储值的表名 (默认 "happydomain")
-oci-tenancy string
NoSQL 数据库所在的 OCI 租户 ID
-oci-user string
访问 NoSQL 数据库的 OCI 用户 ID
```
#### 数据库管理系统
MySQL/Mariadb 等 DBMS 已不再支持,亦无相关计划。
持久化配置
-------------------
二进制文件会自动查找以下配置文件:
* 当前目录下的 `./happydomain.conf`
* `$XDG_CONFIG_HOME/happydomain/happydomain.conf`
* `/etc/happydomain.conf`
仅使用找到的第一个文件。
也可通过命令行参数指定自定义路径:
```sh
./happyDomain /etc/happydomain/config
```
#### 配置文件格式
注释行必须以 # 开头,不支持行尾注释。
每行放置配置选项名称和期望值,用 `=` 分隔。例如:
```
storage-engine=leveldb
leveldb-path=/var/lib/happydomain/db/
```
#### 环境变量
还会查找以 `HAPPYDOMAIN_` 开头的特殊环境变量。
使用以下环境变量可达到与上述示例相同的效果:
```
HAPPYDOMAIN_STORAGE_ENGINE=leveldb
HAPPYDOMAIN_LEVELDB_PATH=/var/lib/happydomain/db/
```
只需将短横线替换为下划线即可。
#### 需要 OVH API
OVH 没有简单的 API 密钥或凭据,需通过 Web 流程获取密钥。
启动认证流程happyDomain 实例需配备专用应用程序密钥。
[连接 OVH请按以下说明操作](https://help.happydomain.org/en/introduction/deploy/ovh)。
构建
--------
### 依赖项
构建 happyDomain 项目需具备以下依赖项:
* `go`
* `nodejs`,已测试版本 22
* `swag`,已测试版本 1.16(可通过 `go install github.com/swaggo/swag/cmd/swag@latest` 安装)。
### 构建步骤
1. 首先准备前端,安装 node 模块依赖:
```bash
pushd web; npm install; popd
```
2. 然后生成 Go 代码使用的资源文件:
```bash
go generate -tags swagger,web ./...
```
3. 最后编译 Go 代码:
```bash
go build -tags swagger,web ./cmd/happyDomain
```
此命令将创建独立二进制文件 `happyDomain`
开发环境
-----------------------
若要为前端做贡献,而非每次修改后都重新生成前端资源(使用 `go generate`),可使用开发工具:
一个终端中使用以下参数运行 happydomain
```bash
./happyDomain -dev http://127.0.0.1:5173
```
另一终端运行 node 部分:
```bash
cd web; npm run dev
```
此设置不使用集成到 go 二进制文件中的静态资源,而是将所有静态资源请求转发至 node 服务器,实现动态重载等功能。

64
SECURITY.md Normal file
View file

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

61
checks/interface.go Normal file
View file

@ -0,0 +1,61 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Package checks provides the registry for domain health checkers.
// It allows individual checker implementations to self-register at startup
// via init() functions and exposes functions to retrieve registered checkers.
package checks // import "git.happydns.org/happyDomain/checks"
import (
"fmt"
"log"
"git.happydns.org/happyDomain/model"
)
// checkersList is the ordered list of all registered checks.
var checkersList map[string]happydns.Checker = map[string]happydns.Checker{}
// RegisterChecker declares the existence of the given check. It is intended to
// be called from init() functions in individual check files so that each check
// self-registers at program startup.
//
// If two checks try to register the same environment name the program will
// terminate: name collisions are a configuration error, not a runtime one.
func RegisterChecker(name string, checker happydns.Checker) {
log.Println("Registering new checker:")
checkersList[name] = checker
}
// GetCheckers returns the ordered list of all registered checks.
func GetCheckers() *map[string]happydns.Checker {
return &checkersList
}
// FindChecker returns the check registered under the given environment name,
// or an error if no check with that name exists.
func FindChecker(name string) (happydns.Checker, error) {
c, ok := checkersList[name]
if !ok {
return nil, fmt.Errorf("unable to find check %q", name)
}
return c, nil
}

View file

@ -23,11 +23,9 @@ package main
import (
"log"
"math/rand"
"os"
"os/signal"
"syscall"
"time"
"github.com/earthboundkid/versioninfo/v2"
"github.com/fatih/color"
@ -63,7 +61,6 @@ func main() {
}
log.Println("This is happyDomain", versioninfo.Short())
rand.Seed(time.Now().UTC().UnixNano())
// Disabled colors in dnscontrol corrections
color.NoColor = true
@ -86,7 +83,7 @@ func main() {
var adminSrv *app.Admin
if opts.AdminBind != "" {
adminSrv := app.NewAdmin(a)
adminSrv = app.NewAdmin(a)
go adminSrv.Start()
}

129
go.mod
View file

@ -2,15 +2,16 @@ module git.happydns.org/happyDomain
go 1.25.0
toolchain go1.26.0
toolchain go1.26.1
require (
github.com/StackExchange/dnscontrol/v4 v4.29.0
github.com/StackExchange/dnscontrol/v4 v4.34.0
github.com/altcha-org/altcha-lib-go v1.0.0
github.com/coreos/go-oidc/v3 v3.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/gin-gonic/gin v1.11.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
@ -26,15 +27,15 @@ require (
github.com/swaggo/swag v1.16.6
github.com/syndtr/goleveldb v1.0.0
github.com/yuin/goldmark v1.7.16
golang.org/x/crypto v0.48.0
golang.org/x/oauth2 v0.35.0
golang.org/x/crypto v0.49.0
golang.org/x/oauth2 v0.36.0
)
require (
cloud.google.com/go/auth v0.18.1 // indirect
cloud.google.com/go/auth v0.18.2 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
codeberg.org/miekg/dns v0.6.40 // indirect
codeberg.org/miekg/dns v0.6.67 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
@ -51,52 +52,52 @@ require (
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/PuerkitoBio/goquery v1.11.0 // indirect
github.com/Shopify/goreferrer v0.0.0-20250617153402-88c1d9a79b05 // indirect
github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2 // indirect
github.com/akamai/AkamaiOPEN-edgegrid-golang/v12 v12.3.0 // indirect
github.com/aliyun/alibaba-cloud-sdk-go v1.63.107 // indirect
github.com/altcha-org/altcha-lib-go v1.0.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.1 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.7 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.7 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.1 // indirect
github.com/aws/aws-sdk-go-v2/service/route53domains v1.34.15 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
github.com/aws/smithy-go v1.24.0 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.3 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.11 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.11 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 // indirect
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.3 // indirect
github.com/aws/aws-sdk-go-v2/service/route53domains v1.34.17 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 // indirect
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
github.com/bits-and-blooms/bitset v1.24.4 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.14.2 // indirect
github.com/bytedance/sonic/loader v0.4.0 // indirect
github.com/centralnicgroup-opensource/rtldev-middleware-go-sdk/v4 v4.0.7 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/centralnicgroup-opensource/rtldev-middleware-go-sdk/v5 v5.0.18 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudflare/cloudflare-go v0.116.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
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.173.0 // 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.5 // indirect
github.com/failsafe-go/failsafe-go v0.9.6 // indirect
github.com/fatih/structs v1.1.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/flosch/pongo2/v4 v4.0.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-gandi/go-gandi v0.7.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
@ -112,22 +113,23 @@ require (
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/gobwas/glob v0.2.4-0.20181002190808-e7a84e9525fe // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/gofrs/flock v0.13.0 // indirect
github.com/gofrs/uuid v4.4.0+incompatible // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a // indirect
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab // indirect
github.com/google/go-querystring v1.2.0 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
github.com/googleapis/gax-go/v2 v2.16.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.12 // indirect
github.com/googleapis/gax-go/v2 v2.17.0 // indirect
github.com/gorilla/context v1.1.2 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
@ -135,7 +137,7 @@ require (
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
github.com/hashicorp/terraform-plugin-log v0.10.0 // indirect
github.com/hetznercloud/hcloud-go/v2 v2.36.0 // indirect
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.186 // indirect
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.187 // indirect
github.com/influxdata/tdigest v0.0.1 // indirect
github.com/iris-contrib/schema v0.0.6 // indirect
github.com/jinzhu/copier v0.4.0 // indirect
@ -148,7 +150,7 @@ require (
github.com/kataras/pio v0.0.14 // indirect
github.com/kataras/sitemap v0.0.6 // indirect
github.com/kataras/tunnel v0.0.4 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
@ -169,8 +171,7 @@ require (
github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04 // indirect
github.com/nrdcg/goinwx v0.12.0 // indirect
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect
github.com/oracle/oci-go-sdk/v65 v65.107.0 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
github.com/oracle/oci-go-sdk/v65 v65.109.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/peterhellberg/link v1.2.0 // indirect
github.com/philhug/opensrs-go v0.0.0-20171126225031-9dfa7433020d // indirect
@ -181,21 +182,21 @@ require (
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/prometheus/procfs v0.20.0 // indirect
github.com/qdm12/reprint v0.0.0-20200326205758-722754a53494 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.58.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/schollz/closestmatch v2.1.0+incompatible // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/softlayer/softlayer-go v1.2.1 // indirect
github.com/softlayer/xmlrpc v0.0.0-20200409220501-5f089df7cb7e // indirect
github.com/sony/gobreaker v1.0.0 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/tdewolff/minify/v2 v2.24.8 // indirect
github.com/tdewolff/parse/v2 v2.8.5 // indirect
github.com/tdewolff/minify/v2 v2.24.9 // indirect
github.com/tdewolff/parse/v2 v2.8.8 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/transip/gotransip/v6 v6.26.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
@ -208,37 +209,39 @@ require (
github.com/vultr/govultr/v2 v2.17.2 // indirect
github.com/yosssi/ace v0.0.5 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.mongodb.org/mongo-driver v1.17.6 // indirect
go.mongodb.org/mongo-driver v1.17.9 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect
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
golang.org/x/arch v0.23.0 // indirect
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/arch v0.24.0 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.41.0 // indirect
google.golang.org/api v0.264.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d // indirect
google.golang.org/grpc v1.78.0 // indirect
golang.org/x/tools v0.42.0 // indirect
google.golang.org/api v0.269.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
google.golang.org/grpc v1.79.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/ini.v1 v1.67.1 // indirect
gopkg.in/mail.v2 v2.3.1 // indirect
gopkg.in/ns1/ns1-go.v2 v2.16.0 // indirect
gopkg.in/ns1/ns1-go.v2 v2.17.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
moul.io/http2curl v1.0.0 // indirect
)
replace github.com/StackExchange/dnscontrol/v4 => github.com/happyDomain/dnscontrol/v4 v4.33.100
replace github.com/StackExchange/dnscontrol/v4 => github.com/happyDomain/dnscontrol/v4 v4.36.100
// https://github.com/kataras/iris/issues/2587
replace github.com/kataras/golog v0.1.15 => github.com/kataras/golog v0.1.13

304
go.sum
View file

@ -1,12 +1,14 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs=
cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA=
cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
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.40 h1:dzO+f0pcQEejc5kQYhOV8pSdJ14uc640M21valACl4s=
codeberg.org/miekg/dns v0.6.40/go.mod h1:fIxAzBMDPnXWSw0fp8+pfZMRiAqYY4+HHYLzUo/S6Dg=
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=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA=
@ -58,8 +60,8 @@ github.com/Shopify/goreferrer v0.0.0-20250617153402-88c1d9a79b05/go.mod h1:NYezi
github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw=
github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2 h1:F1j7z+/DKEsYqZNoxC6wvfmaiDneLsQOFQmuq9NADSY=
github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2/go.mod h1:QlXr/TrICfQ/ANa76sLeQyhAJyNR9sEcfNuZBkY9jgY=
github.com/akamai/AkamaiOPEN-edgegrid-golang/v12 v12.3.0 h1:iGVPe/gPqzpXggbbmVLWR0TyJ9UoPoqKL+kspjseZzE=
github.com/akamai/AkamaiOPEN-edgegrid-golang/v12 v12.3.0/go.mod h1:76JtkiCKMwTdTOlKe9goT4Md+oWjfMouGBQgy+u1bgc=
github.com/aliyun/alibaba-cloud-sdk-go v1.63.107 h1:qagvUyrgOnBIlVRQWOyCZGVKUIYbMBdGdJ104vBpRFU=
github.com/aliyun/alibaba-cloud-sdk-go v1.63.107/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ=
github.com/altcha-org/altcha-lib-go v1.0.0 h1:7oPti0aUS+YCep8nwt5b9g4jYfCU55ZruWESL8G9K5M=
@ -70,42 +72,79 @@ github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kk
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
github.com/aws/aws-sdk-go-v2/config v1.32.7 h1:vxUyWGUwmkQ2g19n7JY/9YL8MfAIl7bTesIUykECXmY=
github.com/aws/aws-sdk-go-v2/config v1.32.7/go.mod h1:2/Qm5vKUU/r7Y+zUk/Ptt2MDAEKAfUtKc1+3U1Mo3oY=
github.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUTvZKeLx4tH6PGQc8=
github.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM=
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/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU=
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.1 h1:1jIdwWOulae7bBLIgB36OZ0DINACb1wxM6wdGlx4eHE=
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.1/go.mod h1:tE2zGlMIlxWv+7Otap7ctRp3qeKqtnja7DZguj3Vu/Y=
github.com/aws/aws-sdk-go-v2/service/route53domains v1.34.15 h1:w+QfByC1CE+dkExfdIqNGVtyqGNE+uxbBCHNLafJ1/0=
github.com/aws/aws-sdk-go-v2/service/route53domains v1.34.15/go.mod h1:gqNlsw/2sJb4sSyhwounZLf+lEAQN9USPoDbD7SbJEE=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 h1:gd84Omyu9JLriJVCbGApcLzVR3XtmC4ZDPcAI6Ftvds=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
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=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/billputer/go-namecheap v0.0.0-20210108011502-994a912fb7f9 h1:2vQTbEJvFsyd1VefzZ34GUkUD6TkJleYYJh9/25WBE4=
@ -118,13 +157,11 @@ github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVk
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/centralnicgroup-opensource/rtldev-middleware-go-sdk/v4 v4.0.7 h1:Jk7uhY5q11fE5PlEupX2Lo12w82UhGC6bE1CI5jwFbc=
github.com/centralnicgroup-opensource/rtldev-middleware-go-sdk/v4 v4.0.7/go.mod h1:FnQtD0+Q/1NZxi0eEWN+3ZRyMsE9vzSB3YjyunkbKD0=
github.com/centralnicgroup-opensource/rtldev-middleware-go-sdk/v5 v5.0.18 h1:RvyTDU0VmnUBd3Qm2i6irEXtCR2KRIxnRlD8l+5z/DY=
github.com/centralnicgroup-opensource/rtldev-middleware-go-sdk/v5 v5.0.18/go.mod h1:a6n4wXFHbMW0iJFxHIJR4PkgG5krP52nOVCBU0m+Obw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@ -144,10 +181,14 @@ 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.173.0 h1:tgzevGhlz9VFjk2y3NmeItUT4vIVVCRFETlG/1GlEQI=
github.com/digitalocean/godo v1.173.0/go.mod h1:xQsWpVCCbkDrWisHA72hPzPlnC+4W5w/McZY5ij9uvU=
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=
github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
@ -155,8 +196,8 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/exoscale/egoscale v0.102.4 h1:GBKsZMIOzwBfSu+4ZmWka3Ejf2JLiaBDHp4CQUgvp2E=
github.com/exoscale/egoscale v0.102.4/go.mod h1:ROSmPtle0wvf91iLZb09++N/9BH2Jo9XxIpAEumvocA=
github.com/failsafe-go/failsafe-go v0.9.5 h1:Bgt4wTKV3+n49GssB2njPZ4u5ApjvtKSIQlqIL4E3oo=
github.com/failsafe-go/failsafe-go v0.9.5/go.mod h1:IeRpglkcwzKagjDMh90ZhN2l4Ovt3+jemQBUbThag54=
github.com/failsafe-go/failsafe-go v0.9.6 h1:vPSH2cry0Ee5cnR9wc9qshCDO6jdrMA9elBJNwyo4Uk=
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=
@ -169,8 +210,8 @@ github.com/flosch/pongo2/v4 v4.0.2 h1:gv+5Pe3vaSVmiJvh/BZa82b7/00YUGm0PIyVVLop0H
github.com/flosch/pongo2/v4 v4.0.2/go.mod h1:B5ObFANs/36VwxxlgKpdchIJHMvHB562PW+BWPhwZD8=
github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
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=
@ -179,6 +220,8 @@ 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=
github.com/go-gandi/go-gandi v0.7.0/go.mod h1:9NoYyfWCjFosClPiWjkbbRK5UViaZ4ctpT8/pKSSFlw=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
@ -218,6 +261,8 @@ github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxE
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
@ -232,8 +277,8 @@ github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy0
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gobwas/glob v0.2.4-0.20181002190808-e7a84e9525fe h1:zn8tqiUbec4wR94o7Qj3LZCAT6uGobhEgnDRg6isG5U=
github.com/gobwas/glob v0.2.4-0.20181002190808-e7a84e9525fe/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
@ -266,8 +311,8 @@ github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8l
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a h1:l7A0loSszR5zHd/qK53ZIHMO8b3bBSmENnQ6eKnUT0A=
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab h1:VYNivV7P8IRHUam2swVUNkhIdp0LRRFKe4hXNnoZKTc=
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
@ -277,7 +322,6 @@ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@ -287,14 +331,12 @@ github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/Z
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao=
github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8=
github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y=
github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/googleapis/enterprise-certificate-proxy v0.3.12 h1:Fg+zsqzYEs1ZnvmcztTYxhgCBsx3eEhEwQ1W/lHq/sQ=
github.com/googleapis/enterprise-certificate-proxy v0.3.12/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
@ -309,8 +351,10 @@ 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.33.100 h1:n9hXu7/rG44ZOMBctwCwCatx1CKQdGjaIkwY2df6g4k=
github.com/happyDomain/dnscontrol/v4 v4.33.100/go.mod h1:q+BnNrB7hMVY2yXgICG/Su/dumLW9HWc4+KVfDTW5YU=
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=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
@ -323,8 +367,8 @@ github.com/hetznercloud/hcloud-go/v2 v2.36.0 h1:HlLL/aaVXUulqe+rsjoJmrxKhPi1MflL
github.com/hetznercloud/hcloud-go/v2 v2.36.0/go.mod h1:MnN/QJEa/RYNQiiVoJjNHPntM7Z1wlYPgJ2HA40/cDE=
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.186 h1:8P/G6KfCsRPraIHAUFfhsfiZuOmuhMpL4jocRru1EYE=
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.186/go.mod h1:M+yna96Fx9o5GbIUnF3OvVvQGjgfVSyeJbV9Yb1z/wI=
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.187 h1:J+U6+eUjIsBhefolFdZW5hQNJbkMj+7msxZrv56Cg2g=
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.187/go.mod h1:M+yna96Fx9o5GbIUnF3OvVvQGjgfVSyeJbV9Yb1z/wI=
github.com/imkira/go-interpol v1.1.0 h1:KIiKr0VSG2CUW1hl1jpiyuzuJeKUUpC8iM1AIE7N1Vk=
github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA=
github.com/influxdata/tdigest v0.0.1 h1:XpFptwYmnEKUqmkcDjrzffswZ3nvNeevbUSLPP/ZzIY=
@ -365,13 +409,12 @@ github.com/kataras/tunnel v0.0.4/go.mod h1:9FkU4LaeifdMWqZu7o20ojmW4B7hdhv2CMLwf
github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b h1:udzkj9S/zlT5X367kqJis0QP7YMxobob6zhzq6Yre00=
github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b/go.mod h1:pcaDhQK0/NJZEvtCO0qQPPropqV0sJOJ6YW7X+9kRwM=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
@ -430,7 +473,6 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04 h1:o6uBwrhM5C8Ll3MAAxrQxRHEu7FkapwTuI2WmL1rw4g=
github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04/go.mod h1:5sN+Lt1CaY4wsPvgQH/jsuJi4XO2ssZbdsIizr4CVC8=
github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/nrdcg/goinwx v0.12.0 h1:ujdUqDBnaRSFwzVnImvPHYw3w3m9XgmGImNUw1GyMb4=
github.com/nrdcg/goinwx v0.12.0/go.mod h1:IrVKd3ZDbFiMjdPgML4CSxZAY9wOoqLvH44zv3NodJ0=
@ -446,12 +488,12 @@ 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.107.0 h1:ZBnDn495o4beF+bidJuIDYubwEVypiOhtVrmIQd0kWY=
github.com/oracle/oci-go-sdk/v65 v65.107.0/go.mod h1:8ZzvzuEG/cFLFZhxg/Mg1w19KqyXBKO3c17QIc5PkGs=
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=
github.com/ovh/go-ovh v1.9.0/go.mod h1:cTVDnl94z4tl8pP1uZ/8jlVxntjSIf09bNcQ5TJSC7c=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/peterhellberg/link v1.2.0 h1:UA5pg3Gp/E0F2WdX7GERiNrPQrM1K6CVJUUWfHa4t6c=
@ -476,12 +518,14 @@ github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTU
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=
github.com/qdm12/reprint v0.0.0-20200326205758-722754a53494/go.mod h1:yipyliwI08eQ6XwDm1fEwKPdF/xdbkiHtrU+1Hg+vc4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.58.0 h1:ggY2pvZaVdB9EyojxL1p+5mptkuHyX5MOSv4dgWF4Ug=
github.com/quic-go/quic-go v0.58.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/robertkrimen/otto v0.5.1 h1:avDI4ToRk8k1hppLdYFTuuzND41n37vPGJU7547dGf0=
github.com/robertkrimen/otto v0.5.1/go.mod h1:bS433I4Q9p+E5pZLu7r17vP6FkE6/wLxBdmKjoqJXF8=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@ -498,14 +542,11 @@ github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs=
github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hgR6gDIPg=
github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM=
github.com/softlayer/softlayer-go v1.2.1 h1:8ucHxn5laVsVPb0/aMGnr6tOMt1I9BgEtU5mn70OGKw=
@ -516,7 +557,6 @@ github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ=
github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
@ -542,10 +582,10 @@ github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
github.com/tdewolff/minify/v2 v2.24.8 h1:58/VjsbevI4d5FGV0ZSuBrHMSSkH4MCH0sIz/eKIauE=
github.com/tdewolff/minify/v2 v2.24.8/go.mod h1:0Ukj0CRpo/sW/nd8uZ4ccXaV1rEVIWA3dj8U7+Shhfw=
github.com/tdewolff/parse/v2 v2.8.5 h1:ZmBiA/8Do5Rpk7bDye0jbbDUpXXbCdc3iah4VeUvwYU=
github.com/tdewolff/parse/v2 v2.8.5/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
github.com/tdewolff/minify/v2 v2.24.9 h1:W6A570F9N6MuZtg9mdHXD93piZZIWJaGpbAw9Narrfw=
github.com/tdewolff/minify/v2 v2.24.9/go.mod h1:9F66jUzl/Pdf6Q5x0RXFUsI/8N1kjBb3ILg9ABSWoOI=
github.com/tdewolff/parse/v2 v2.8.8 h1:l3yOJ4OUKq1sKeQQxZ7P2yZ6daW/Oq4IDxL98uTOpPI=
github.com/tdewolff/parse/v2 v2.8.8/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE=
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
@ -601,34 +641,38 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/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.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss=
go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU=
go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.uber.org/ratelimit v0.3.1 h1:K4qVE+byfv/B3tC+4nYWP7v/6SimcO7HzHekoMNBma0=
go.uber.org/ratelimit v0.3.1/go.mod h1:6euWsTB6U/Nb3X++xEUXA8ciPJvr19Q/0h1+oDcJhRk=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
@ -642,14 +686,16 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v
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=
golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
@ -664,8 +710,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@ -686,11 +732,15 @@ 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.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
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=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -703,12 +753,13 @@ 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=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -722,7 +773,6 @@ golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220406163625-3f8b81556e12/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@ -734,6 +784,8 @@ 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=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@ -757,6 +809,8 @@ 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=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@ -765,7 +819,6 @@ golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGm
golang.org/x/tools v0.0.0-20190206041539-40960b6deb8e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
@ -774,8 +827,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@ -788,24 +841,26 @@ 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.264.0 h1:+Fo3DQXBK8gLdf8rFZ3uLu39JpOnhvzJrLMQSoSYZJM=
google.golang.org/api v0.264.0/go.mod h1:fAU1xtNNisHgOF5JooAs8rRaTkl2rT3uaoNGo9NS3R8=
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=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb0NcTum6itIWtdoglGX+rnGxm2934=
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d h1:xXzuihhT3gL/ntduUZwHECzAn57E8dA6l8SOtYWdD8Q=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM=
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM=
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M=
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@ -823,16 +878,15 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/h2non/gock.v1 v1.0.15/go.mod h1:sX4zAkdYX1TRGJ2JY156cFspQn4yRWn6p9EMdODlynE=
gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY=
gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0=
gopkg.in/ini.v1 v1.51.1/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k=
gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk=
gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw=
gopkg.in/ns1/ns1-go.v2 v2.16.0 h1:mUczKFnrCystSV7yIODzVSbENoud3T7DwstmyVZfqg4=
gopkg.in/ns1/ns1-go.v2 v2.16.0/go.mod h1:pfaU0vECVP7DIOr453z03HXS6dFJpXdNRwOyRzwmPSc=
gopkg.in/ns1/ns1-go.v2 v2.17.2 h1:x8YKHqCJWkC/hddfUhw7FRqTG0x3fr/0ZnWYN+i4THs=
gopkg.in/ns1/ns1-go.v2 v2.17.2/go.mod h1:pfaU0vECVP7DIOr453z03HXS6dFJpXdNRwOyRzwmPSc=
gopkg.in/sourcemap.v1 v1.0.5 h1:inv58fC9f9J3TK2Y2R1NPntXEn3/wjWHkonhIUODNTI=
gopkg.in/sourcemap.v1 v1.0.5/go.mod h1:2RlvNNSMglmRrcvhfuzp4hQHwOtjxlbjX7UPY/GXb78=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=

View file

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

View file

@ -0,0 +1,279 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package adapter_test
import (
"net"
"testing"
"github.com/miekg/dns"
adapter "git.happydns.org/happyDomain/internal/adapters"
"git.happydns.org/happyDomain/model"
)
func makeA(name string, ip string) happydns.Record {
return &dns.A{
Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 300},
A: net.ParseIP(ip),
}
}
func makeMX(name string, pref uint16, mx string) happydns.Record {
return &dns.MX{
Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeMX, Class: dns.ClassINET, Ttl: 300},
Preference: pref,
Mx: mx,
}
}
func TestBuildTargetRecords_AllSelected(t *testing.T) {
providerRecords := []happydns.Record{
makeA("example.com.", "1.2.3.4"),
}
newRecord := makeA("example.com.", "5.6.7.8")
corrections := []*happydns.Correction{
{
Id: happydns.Identifier([]byte("add-1")),
Kind: happydns.CorrectionKindAddition,
NewRecords: []happydns.Record{newRecord},
},
}
selectedIDs := []happydns.Identifier{
happydns.Identifier([]byte("add-1")),
}
result := adapter.BuildTargetRecords(providerRecords, corrections, selectedIDs)
if len(result) != 2 {
t.Fatalf("expected 2 records, got %d", len(result))
}
}
func TestBuildTargetRecords_NoneSelected(t *testing.T) {
providerRecords := []happydns.Record{
makeA("example.com.", "1.2.3.4"),
}
corrections := []*happydns.Correction{
{
Id: happydns.Identifier([]byte("add-1")),
Kind: happydns.CorrectionKindAddition,
NewRecords: []happydns.Record{makeA("example.com.", "5.6.7.8")},
},
}
result := adapter.BuildTargetRecords(providerRecords, corrections, nil)
if len(result) != 1 {
t.Fatalf("expected 1 record, got %d", len(result))
}
if result[0].String() != providerRecords[0].String() {
t.Errorf("expected unchanged provider record, got %s", result[0].String())
}
}
func TestBuildTargetRecords_Deletion(t *testing.T) {
providerRecords := []happydns.Record{
makeA("example.com.", "1.2.3.4"),
makeA("example.com.", "5.6.7.8"),
}
corrections := []*happydns.Correction{
{
Id: happydns.Identifier([]byte("del-1")),
Kind: happydns.CorrectionKindDeletion,
OldRecords: []happydns.Record{makeA("example.com.", "1.2.3.4")},
},
}
selectedIDs := []happydns.Identifier{
happydns.Identifier([]byte("del-1")),
}
result := adapter.BuildTargetRecords(providerRecords, corrections, selectedIDs)
if len(result) != 1 {
t.Fatalf("expected 1 record, got %d", len(result))
}
if result[0].String() != providerRecords[1].String() {
t.Errorf("expected remaining record %s, got %s", providerRecords[1].String(), result[0].String())
}
}
func TestBuildTargetRecords_Update(t *testing.T) {
oldRecord := makeA("example.com.", "1.2.3.4")
newRecord := makeA("example.com.", "9.8.7.6")
providerRecords := []happydns.Record{oldRecord}
corrections := []*happydns.Correction{
{
Id: happydns.Identifier([]byte("upd-1")),
Kind: happydns.CorrectionKindUpdate,
OldRecords: []happydns.Record{oldRecord},
NewRecords: []happydns.Record{newRecord},
},
}
selectedIDs := []happydns.Identifier{
happydns.Identifier([]byte("upd-1")),
}
result := adapter.BuildTargetRecords(providerRecords, corrections, selectedIDs)
if len(result) != 1 {
t.Fatalf("expected 1 record, got %d", len(result))
}
if result[0].String() != newRecord.String() {
t.Errorf("expected updated record %s, got %s", newRecord.String(), result[0].String())
}
}
func TestBuildTargetRecords_PartialSelection(t *testing.T) {
providerRecords := []happydns.Record{
makeA("example.com.", "1.2.3.4"),
}
corrections := []*happydns.Correction{
{
Id: happydns.Identifier([]byte("add-1")),
Kind: happydns.CorrectionKindAddition,
NewRecords: []happydns.Record{makeA("example.com.", "5.6.7.8")},
},
{
Id: happydns.Identifier([]byte("add-2")),
Kind: happydns.CorrectionKindAddition,
NewRecords: []happydns.Record{makeMX("example.com.", 10, "mail.example.com.")},
},
}
// Only select the first correction.
selectedIDs := []happydns.Identifier{
happydns.Identifier([]byte("add-1")),
}
result := adapter.BuildTargetRecords(providerRecords, corrections, selectedIDs)
if len(result) != 2 {
t.Fatalf("expected 2 records, got %d", len(result))
}
}
func TestBuildTargetRecords_MixedOperations(t *testing.T) {
providerRecords := []happydns.Record{
makeA("example.com.", "1.2.3.4"),
makeA("example.com.", "10.0.0.1"),
}
corrections := []*happydns.Correction{
{
Id: happydns.Identifier([]byte("del-1")),
Kind: happydns.CorrectionKindDeletion,
OldRecords: []happydns.Record{makeA("example.com.", "10.0.0.1")},
},
{
Id: happydns.Identifier([]byte("add-1")),
Kind: happydns.CorrectionKindAddition,
NewRecords: []happydns.Record{makeA("example.com.", "5.6.7.8")},
},
}
selectedIDs := []happydns.Identifier{
happydns.Identifier([]byte("del-1")),
happydns.Identifier([]byte("add-1")),
}
result := adapter.BuildTargetRecords(providerRecords, corrections, selectedIDs)
if len(result) != 2 {
t.Fatalf("expected 2 records, got %d", len(result))
}
// Should have 1.2.3.4 and 5.6.7.8 (10.0.0.1 deleted, 5.6.7.8 added)
found := map[string]bool{}
for _, r := range result {
found[r.String()] = true
}
if !found[makeA("example.com.", "1.2.3.4").String()] {
t.Error("expected record 1.2.3.4 to remain")
}
if !found[makeA("example.com.", "5.6.7.8").String()] {
t.Error("expected record 5.6.7.8 to be added")
}
}
func TestDNSControlDiffByRecord_EnrichedFields(t *testing.T) {
oldRecords := []happydns.Record{
makeA("example.com.", "1.2.3.4"),
}
newRecords := []happydns.Record{
makeA("example.com.", "1.2.3.4"),
makeA("example.com.", "5.6.7.8"),
}
corrections, nbDiffs, err := adapter.DNSControlDiffByRecord(oldRecords, newRecords, "example.com.")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if nbDiffs == 0 {
t.Fatal("expected at least 1 diff")
}
if len(corrections) == 0 {
t.Fatal("expected at least 1 correction")
}
for _, c := range corrections {
if len(c.Id) == 0 {
t.Error("expected correction to have an ID")
}
switch c.Kind {
case happydns.CorrectionKindAddition:
if len(c.NewRecords) == 0 {
t.Error("addition correction should have NewRecords")
}
case happydns.CorrectionKindDeletion:
if len(c.OldRecords) == 0 {
t.Error("deletion correction should have OldRecords")
}
case happydns.CorrectionKindUpdate:
if len(c.OldRecords) == 0 || len(c.NewRecords) == 0 {
t.Error("update correction should have both OldRecords and NewRecords")
}
}
}
}
func TestDNSControlDiffByRecord_NoChanges(t *testing.T) {
records := []happydns.Record{
makeA("example.com.", "1.2.3.4"),
}
corrections, _, err := adapter.DNSControlDiffByRecord(records, records, "example.com.")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(corrections) != 0 {
t.Errorf("expected 0 corrections for identical zones, got %d", len(corrections))
}
}

View file

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

View file

@ -83,18 +83,18 @@ func (bc *BackupController) DoBackup() (ret happydns.Backup) {
for _, dn := range ds {
// Domain logs
ls, err := bc.store.ListDomainLogs(dn)
if err != nil {
ret.Errors = append(ret.Errors, fmt.Sprintf("unable to retrieve domain's logs %s/%s (%s): %s", u.Id.String(), dn.Id.String(), dn.DomainName, err.Error()))
ls, logErr := bc.store.ListDomainLogs(dn)
if logErr != nil {
ret.Errors = append(ret.Errors, fmt.Sprintf("unable to retrieve domain's logs %s/%s (%s): %s", u.Id.String(), dn.Id.String(), dn.DomainName, logErr.Error()))
} else {
ret.DomainsLogs[dn.Id.String()] = ls
}
// Zones
for _, zid := range dn.ZoneHistory {
z, err := bc.store.GetZone(zid)
if err != nil {
ret.Errors = append(ret.Errors, fmt.Sprintf("unable to retrieve domain's zone %s/%s (%s): zoneid=%s: %s", u.Id.String(), dn.Id.String(), dn.DomainName, zid.String(), err.Error()))
z, zoneErr := bc.store.GetZone(zid)
if zoneErr != nil {
ret.Errors = append(ret.Errors, fmt.Sprintf("unable to retrieve domain's zone %s/%s (%s): zoneid=%s: %s", u.Id.String(), dn.Id.String(), dn.DomainName, zid.String(), zoneErr.Error()))
} else {
ret.Zones = append(ret.Zones, z)
}

View file

@ -42,7 +42,12 @@ type DomainController struct {
store domain.DomainStorage
}
func NewDomainController(duService happydns.DomainUsecase, remoteZoneImporter happydns.RemoteZoneImporterUsecase, zoneImporter happydns.ZoneImporterUsecase, store domain.DomainStorage) *DomainController {
func NewDomainController(
duService happydns.DomainUsecase,
remoteZoneImporter happydns.RemoteZoneImporterUsecase,
zoneImporter happydns.ZoneImporterUsecase,
store domain.DomainStorage,
) *DomainController {
return &DomainController{
duService,
remoteZoneImporter,

View file

@ -40,7 +40,12 @@ type ZoneController struct {
store zone.ZoneStorage
}
func NewZoneController(domainService happydns.DomainUsecase, zoneService happydns.ZoneUsecase, zoneCorrectionService happydns.ZoneCorrectionApplierUsecase, store zone.ZoneStorage) *ZoneController {
func NewZoneController(
domainService happydns.DomainUsecase,
zoneService happydns.ZoneUsecase,
zoneCorrectionService happydns.ZoneCorrectionApplierUsecase,
store zone.ZoneStorage,
) *ZoneController {
return &ZoneController{
domainService,
zoneService,

View file

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

View file

@ -58,7 +58,7 @@ func NewLoginController(authService happydns.AuthenticationUsecase, captchaVerif
// @Security securitydefinitions.basic
// @Success 200 {object} happydns.User
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
// @Router /auth/user [get]
// @Router /auth [get]
func (lc *LoginController) GetLoggedUser(c *gin.Context) {
c.JSON(http.StatusOK, c.MustGet("LoggedUser"))
}
@ -86,11 +86,11 @@ func (lc *LoginController) Login(c *gin.Context) {
return
}
// Check if captcha is required for this IP/email combination
if lc.captcha.Provider() != "" {
requiresCaptcha := lc.failureTracker.RequiresCaptcha(c.ClientIP(), request.Email)
if requiresCaptcha {
// Enforce captcha when a provider is configured and the failure threshold
// is reached. Failure tracking runs unconditionally so it stays effective
// even on deployments without a captcha provider.
if lc.failureTracker.RequiresCaptcha(c.ClientIP(), request.Email) {
if lc.captcha.Provider() != "" {
if request.CaptchaToken == "" {
c.JSON(http.StatusUnauthorized, happydns.LoginErrorResponse{
Message: "Captcha verification required.",
@ -99,7 +99,7 @@ func (lc *LoginController) Login(c *gin.Context) {
return
}
if err := lc.captcha.Verify(request.CaptchaToken, c.ClientIP()); err != nil {
if err = lc.captcha.Verify(request.CaptchaToken, c.ClientIP()); err != nil {
log.Printf("%s: captcha verification failed: %s", c.ClientIP(), err.Error())
c.JSON(http.StatusUnauthorized, happydns.LoginErrorResponse{
Message: "Captcha verification failed.",
@ -107,31 +107,41 @@ func (lc *LoginController) Login(c *gin.Context) {
})
return
}
} else {
// No captcha provider — signal a plain rate-limit to the client.
c.JSON(http.StatusTooManyRequests, happydns.LoginErrorResponse{
Message: "Too many failed login attempts. Please wait before trying again.",
RateLimited: true,
})
return
}
}
user, err := lc.authService.AuthenticateUserWithPassword(request)
if err != nil {
log.Printf("%s: %s", c.ClientIP(), err.Error())
log.Printf("%s %s: %s", c.ClientIP(), request.Email, err.Error())
if lc.captcha.Provider() != "" {
lc.failureTracker.RecordFailure(c.ClientIP(), request.Email)
if lc.failureTracker.RequiresCaptcha(c.ClientIP(), request.Email) {
lc.failureTracker.RecordFailure(c.ClientIP(), request.Email)
if lc.failureTracker.RequiresCaptcha(c.ClientIP(), request.Email) {
if lc.captcha.Provider() != "" {
c.JSON(http.StatusUnauthorized, happydns.LoginErrorResponse{
Message: "Invalid username or password.",
CaptchaRequired: true,
})
return
} else {
c.JSON(http.StatusTooManyRequests, happydns.LoginErrorResponse{
Message: "Too many failed login attempts. Please wait before trying again.",
RateLimited: true,
})
}
return
}
c.JSON(http.StatusUnauthorized, happydns.LoginErrorResponse{Message: "Invalid username or password."})
return
}
if lc.captcha.Provider() != "" {
lc.failureTracker.RecordSuccess(c.ClientIP(), request.Email)
}
lc.failureTracker.RecordSuccess(c.ClientIP(), request.Email)
middleware.SessionLoginOK(c, user)

View file

@ -26,9 +26,8 @@ package controller
import (
"context"
"crypto/rand"
"crypto/sha1"
"crypto/sha256"
"encoding/hex"
"fmt"
"log"
"net/http"
"strings"
@ -45,6 +44,9 @@ import (
const (
SESSION_KEY_OIDC_STATE = "oidc-state"
SESSION_KEY_OIDC_PKCE = "oidc-pkce"
SESSION_KEY_OIDC_NONCE = "oidc-nonce"
SESSION_KEY_OIDC_NEXT = "oidc-next"
)
type OIDCProvider struct {
@ -94,6 +96,12 @@ func NewOIDCProvider(cfg *happydns.Options, authService happydns.AuthenticationU
func (p *OIDCProvider) RedirectOIDC(c *gin.Context) {
session := sessions.Default(c)
// Capture and validate the post-login redirect destination.
// Only accept same-origin relative paths to prevent open redirect.
if next := c.Query("next"); next != "" && strings.HasPrefix(next, "/") && !strings.HasPrefix(next, "//") {
session.Set(SESSION_KEY_OIDC_NEXT, next)
}
state := make([]byte, 32)
_, err := rand.Read(state)
if err != nil {
@ -102,7 +110,19 @@ func (p *OIDCProvider) RedirectOIDC(c *gin.Context) {
return
}
nonce := make([]byte, 32)
if _, err = rand.Read(nonce); err != nil {
log.Println("Unable to redirect_OIDC, rand.Read fails:", err)
c.AbortWithStatusJSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: "Sorry, we are currently unable to respond to your request. Please retry later."})
return
}
nonceStr := hex.EncodeToString(nonce)
pkceVerifier := oauth2.GenerateVerifier()
session.Set(SESSION_KEY_OIDC_STATE, hex.EncodeToString(state))
session.Set(SESSION_KEY_OIDC_PKCE, pkceVerifier)
session.Set(SESSION_KEY_OIDC_NONCE, nonceStr)
err = session.Save()
if err != nil {
log.Println("Unable to redirect_OIDC, session.Save fails:", err)
@ -110,7 +130,7 @@ func (p *OIDCProvider) RedirectOIDC(c *gin.Context) {
return
}
c.Redirect(http.StatusFound, p.oauth2config.AuthCodeURL(hex.EncodeToString(state)))
c.Redirect(http.StatusFound, p.oauth2config.AuthCodeURL(hex.EncodeToString(state), oauth2.S256ChallengeOption(pkceVerifier), oauth2.SetAuthURLParam("nonce", nonceStr)))
}
func (p *OIDCProvider) CompleteOIDC(c *gin.Context) {
@ -124,34 +144,51 @@ func (p *OIDCProvider) CompleteOIDC(c *gin.Context) {
return
}
session.Delete(SESSION_KEY_OIDC_STATE)
err := session.Save()
if err != nil {
log.Println("Unable to CompleteOIDC, session.Save fails:", err)
c.AbortWithStatusJSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: "Sorry, we are currently unable to respond to your request. Please retry later."})
return
}
pkceVerifier, _ := session.Get(SESSION_KEY_OIDC_PKCE).(string)
expectedNonce, _ := session.Get(SESSION_KEY_OIDC_NONCE).(string)
nextPath, _ := session.Get(SESSION_KEY_OIDC_NEXT).(string)
oauth2Token, err := p.oauth2config.Exchange(c, c.Query("code"))
// Consume the OIDC session keys in-memory now. The actual session.Save()
// is deferred to SessionLoginOK (via session.Clear + new session). On any
// error below the in-memory changes are discarded, preserving the session
// keys so the user can retry without restarting the whole flow. The
// authorization code itself is single-use at the provider level, so
// replaying the callback with the same code is rejected there.
session.Delete(SESSION_KEY_OIDC_STATE)
session.Delete(SESSION_KEY_OIDC_PKCE)
session.Delete(SESSION_KEY_OIDC_NONCE)
session.Delete(SESSION_KEY_OIDC_NEXT)
oauth2Token, err := p.oauth2config.Exchange(c, c.Query("code"), oauth2.VerifierOption(pkceVerifier))
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: fmt.Sprintf("Failed to exchange token: %s", err.Error())})
log.Printf("CompleteOIDC: failed to exchange token: %s", err.Error())
c.AbortWithStatusJSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: "Sorry, we are currently unable to respond to your request. Please retry later."})
return
}
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
if !ok {
c.AbortWithStatusJSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: "No id_token field in oauth2 token."})
log.Printf("CompleteOIDC: no id_token field in oauth2 token")
c.AbortWithStatusJSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: "Sorry, we are currently unable to respond to your request. Please retry later."})
return
}
idToken, err := p.oidcVerifier.Verify(c, rawIDToken)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: fmt.Sprintf("Failed to verify ID Token: %s", err.Error())})
log.Printf("CompleteOIDC: failed to verify ID Token: %s", err.Error())
c.AbortWithStatusJSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: "Sorry, we are currently unable to respond to your request. Please retry later."})
return
}
var claims map[string]interface{}
if err := idToken.Claims(&claims); err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: fmt.Sprintf("Unable to retrieve user profile: %s", err.Error())})
if idToken.Nonce != expectedNonce {
log.Printf("CompleteOIDC: nonce mismatch: got %q, expected %q", idToken.Nonce, expectedNonce)
c.AbortWithStatusJSON(http.StatusBadRequest, happydns.ErrorResponse{Message: "Invalid nonce in ID token"})
return
}
var claims map[string]any
if err = idToken.Claims(&claims); err != nil {
log.Printf("CompleteOIDC: unable to retrieve user profile: %s", err.Error())
c.AbortWithStatusJSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: "Sorry, we are currently unable to respond to your request. Please retry later."})
return
}
@ -171,17 +208,22 @@ func (p *OIDCProvider) CompleteOIDC(c *gin.Context) {
return
}
hash := sha1.Sum([]byte(profile.Email))
hash := sha256.Sum256([]byte(profile.Email))
profile.Id = hash[:]
}
_, err = p.authService.CompleteAuthentication(&profile)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: fmt.Sprintf("Unable to complete authentication: %s", err.Error())})
log.Printf("CompleteOIDC: unable to complete authentication: %s", err.Error())
c.AbortWithStatusJSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: "Sorry, we are currently unable to respond to your request. Please retry later."})
return
}
middleware.SessionLoginOK(c, &profile)
c.Redirect(http.StatusFound, p.config.GetBaseURL()+"/")
redirectTo := p.config.GetBaseURL() + "/"
if nextPath != "" {
redirectTo = p.config.GetBaseURL() + nextPath
}
c.Redirect(http.StatusFound, redirectTo)
}

View file

@ -234,7 +234,7 @@ func (dc *DomainController) RetrieveZone(c *gin.Context) {
}
domain := c.MustGet("domain").(*happydns.Domain)
zone, err := dc.remoteZoneImporter.Import(user, domain)
zone, err := dc.remoteZoneImporter.Import(c.Request.Context(), user, domain)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return

View file

@ -48,13 +48,13 @@ type DNSMsg struct {
Question []DNSQuestion
// Answer is the list of Answer records in the DNS response.
Answer []interface{} `swaggertype:"object"`
Answer []any `swaggertype:"object"`
// Ns is the list of Authoritative records in the DNS response.
Ns []interface{} `swaggertype:"object"`
Ns []any `swaggertype:"object"`
// Extra is the list of extra records in the DNS response.
Extra []interface{} `swaggertype:"object"`
Extra []any `swaggertype:"object"`
}
type DNSQuestion struct {

View file

@ -93,7 +93,7 @@ func (sc *ServiceController) AddZoneService(c *gin.Context) {
c.JSON(http.StatusOK, zone)
}
// GetServiceService retrieves the designated Service.
// GetZoneService retrieves the designated Service.
//
// @Summary Get the Service.
// @Schemes

View file

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

View file

@ -99,7 +99,7 @@ func (sc *SessionController) GetSession(c *gin.Context) {
// @Accept json
// @Prodsce json
// @Security securitydefinitions.basic
// @Ssccess 204 {null} null
// @Ssccess 204
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
// @Router /session [delete]
func (sc *SessionController) ClearSession(c *gin.Context) {
@ -230,16 +230,16 @@ func (sc *SessionController) UpdateSession(c *gin.Context) {
return
}
s, err := sc.sessionService.GetUserSession(myuser, c.Param("sid"))
err = sc.sessionService.UpdateUserSession(myuser, c.Param("sid"), func(newsession *happydns.Session) {
newsession.Description = us.Description
newsession.ExpiresOn = us.ExpiresOn
})
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
err = sc.sessionService.UpdateUserSession(myuser, c.Param("sid"), func(newsession *happydns.Session) {
newsession.Description = us.Description
newsession.ExpiresOn = us.ExpiresOn
})
s, err := sc.sessionService.GetUserSession(myuser, c.Param("sid"))
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
@ -256,9 +256,8 @@ func (sc *SessionController) UpdateSession(c *gin.Context) {
// @Tags users
// @Accept json
// @Param sessionId path string true "Session identifier"
// @Prodsce json
// @Security securitydefinitions.basic
// @Ssccess 200 {object} happydns.Session
// @Success 204 {null} null
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
// @Router /sessions/{sessionId} [delete]
func (sc *SessionController) DeleteSession(c *gin.Context) {
@ -274,5 +273,5 @@ func (sc *SessionController) DeleteSession(c *gin.Context) {
return
}
c.JSON(http.StatusOK, nil)
c.Status(http.StatusNoContent)
}

View file

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

View file

@ -107,7 +107,7 @@ func (zc *ZoneController) DiffZonesHandler(c *gin.Context) {
var corrections []*happydns.Correction
if c.Param("oldzoneid") == "@" {
var err error
corrections, nbDiffs, err = zc.zoneCorrectionService.List(user, domain, newzone)
corrections, nbDiffs, err = zc.zoneCorrectionService.List(c.Request.Context(), user, domain, newzone)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
@ -214,7 +214,7 @@ func (zc *ZoneController) ApplyZoneCorrections(c *gin.Context) {
return
}
newZone, err := zc.zoneCorrectionService.Apply(user, domain, zone, &form)
newZone, err := zc.zoneCorrectionService.Apply(c.Request.Context(), user, domain, zone, &form)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
@ -223,6 +223,46 @@ func (zc *ZoneController) ApplyZoneCorrections(c *gin.Context) {
c.JSON(http.StatusOK, newZone.ZoneMeta)
}
// PrepareZoneCorrections computes the executable corrections without applying them.
//
// @Summary Preview the corrections the provider will execute.
// @Schemes
// @Description Compute the executable corrections for the selected changes without applying them.
// @Tags zones
// @Accept json
// @Produce json
// @Security securitydefinitions.basic
// @Param domainId path string true "Domain identifier"
// @Param zoneId path string true "Zone identifier"
// @Param body body happydns.PrepareZoneForm true "Selected corrections to prepare"
// @Success 200 {object} happydns.PrepareZoneResponse "The executable corrections"
// @Failure 400 {object} happydns.ErrorResponse "Invalid input"
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
// @Failure 404 {object} happydns.ErrorResponse "Domain or Zone not found"
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domainId}/zone/{zoneId}/prepare_changes [post]
func (zc *ZoneController) PrepareZoneCorrections(c *gin.Context) {
user := c.MustGet("LoggedUser").(*happydns.User)
domain := c.MustGet("domain").(*happydns.Domain)
zone := c.MustGet("zone").(*happydns.Zone)
var form happydns.PrepareZoneForm
err := c.ShouldBindJSON(&form)
if err != nil {
log.Printf("%s sends invalid PrepareZoneForm JSON: %s", c.ClientIP(), err.Error())
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": fmt.Sprintf("Something is wrong in received data: %s", err.Error())})
return
}
response, err := zc.zoneCorrectionService.Prepare(c.Request.Context(), user, domain, zone, &form)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, response)
}
// ExportZone creates a flatten export of the zone.
//
// @Summary Get flatten zone file.
@ -279,8 +319,9 @@ func (zc *ZoneController) AddRecords(c *gin.Context) {
return
}
var rr happydns.Record
for _, record := range records {
rr, err := helpers.ParseRecord(record, domain.DomainName)
rr, err = helpers.ParseRecord(record, domain.DomainName)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
@ -340,8 +381,9 @@ func (zc *ZoneController) DeleteRecords(c *gin.Context) {
return
}
var rr happydns.Record
for _, record := range records {
rr, err := helpers.ParseRecord(record, domain.DomainName)
rr, err = helpers.ParseRecord(record, domain.DomainName)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return

View file

@ -26,8 +26,9 @@ import (
"log"
"net/http"
"github.com/gin-contrib/sessions"
ginsessions "github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
gorillasessions "github.com/gorilla/sessions"
"git.happydns.org/happyDomain/model"
)
@ -43,15 +44,49 @@ func AuthRequired() gin.HandlerFunc {
}
}
// gorillasessionExposer is satisfied by the concrete gin-contrib/sessions
// type, which wraps a *gorilla/sessions.Session and exposes it via Session().
// Using a duck-typed local interface avoids importing gin-contrib internals.
type gorillasessionExposer interface {
Session() *gorillasessions.Session
}
func SessionLoginOK(c *gin.Context, user happydns.UserInfo) error {
session := sessions.Default(c)
session := ginsessions.Default(c)
// Phase 1: invalidate the pre-login session to prevent session fixation.
// Preserve the original session options (Secure flag, Path, MaxAge) so
// we can restore them on the new session.
// Setting MaxAge=-1 causes the store to delete the server-side record and
// send an expired cookie on Save().
var origOptions *gorillasessions.Options
if gs, ok := session.(gorillasessionExposer); ok {
if gs.Session().Options != nil {
opts := *gs.Session().Options // copy by value
origOptions = &opts
}
}
session.Clear()
session.Options(ginsessions.Options{MaxAge: -1})
session.Save()
// Phase 2: create a genuinely new session with a fresh ID.
// Reset the gorilla session's ID so the store generates a new one,
// then restore the original cookie options.
if gs, ok := session.(gorillasessionExposer); ok {
gs.Session().ID = ""
if origOptions != nil {
origOptions.MaxAge = 86400 * 30 // restore positive MaxAge
gs.Session().Options = origOptions
}
}
session.Set("iduser", user.GetUserId())
err := session.Save()
if err != nil {
return happydns.InternalError{
Err: fmt.Errorf("failed to save save user session: %s", err),
Err: fmt.Errorf("failed to save user session: %s", err),
UserMessage: "Invalid username or password.",
}
}

View file

@ -64,7 +64,7 @@ func JwtAuthMiddleware(authService happydns.AuthenticationUsecase, signingMethod
// Validate the token and retrieve claims
claims := &UserClaims{}
_, err := jwt.ParseWithClaims(token, claims,
func(token *jwt.Token) (interface{}, error) {
func(token *jwt.Token) (any, error) {
return secretKey, nil
}, jwt.WithValidMethods([]string{signingMethod}))
if err != nil {

View file

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

View file

@ -29,7 +29,17 @@ import (
"git.happydns.org/happyDomain/model"
)
func DeclareDomainRoutes(router *gin.RouterGroup, domainUC happydns.DomainUsecase, domainLogUC happydns.DomainLogUsecase, remoteZoneImporter happydns.RemoteZoneImporterUsecase, zoneImporter happydns.ZoneImporterUsecase, zoneUC happydns.ZoneUsecase, zoneCorrApplier happydns.ZoneCorrectionApplierUsecase, zoneServiceUC happydns.ZoneServiceUsecase, serviceUC happydns.ServiceUsecase) {
func DeclareDomainRoutes(
router *gin.RouterGroup,
domainUC happydns.DomainUsecase,
domainLogUC happydns.DomainLogUsecase,
remoteZoneImporter happydns.RemoteZoneImporterUsecase,
zoneImporter happydns.ZoneImporterUsecase,
zoneUC happydns.ZoneUsecase,
zoneCorrApplier happydns.ZoneCorrectionApplierUsecase,
zoneServiceUC happydns.ZoneServiceUsecase,
serviceUC happydns.ServiceUsecase,
) {
dc := controller.NewDomainController(
domainUC,
remoteZoneImporter,
@ -51,5 +61,12 @@ func DeclareDomainRoutes(router *gin.RouterGroup, domainUC happydns.DomainUsecas
apiDomainsRoutes.POST("/zone", dc.ImportZone)
apiDomainsRoutes.POST("/retrieve_zone", dc.RetrieveZone)
DeclareZoneRoutes(apiDomainsRoutes, zoneUC, domainUC, zoneCorrApplier, zoneServiceUC, serviceUC)
DeclareZoneRoutes(
apiDomainsRoutes,
zoneUC,
domainUC,
zoneCorrApplier,
zoneServiceUC,
serviceUC,
)
}

View file

@ -72,14 +72,21 @@ type Dependencies struct {
// @name Authorization
// @description Description for what is this security definition being used
func DeclareRoutes(cfg *happydns.Options, router *gin.Engine, dep Dependencies) {
func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, dep Dependencies) {
baseRoutes := router.Group("")
declareRouteSwagger(cfg, baseRoutes)
apiRoutes := router.Group("/api")
lc := DeclareAuthenticationRoutes(cfg, baseRoutes, apiRoutes, dep.Authentication, dep.CaptchaVerifier, dep.FailureTracker)
lc := DeclareAuthenticationRoutes(
cfg,
baseRoutes,
apiRoutes,
dep.Authentication,
dep.CaptchaVerifier,
dep.FailureTracker,
)
auc := DeclareAuthUserRoutes(apiRoutes, dep.AuthUser, lc)
DeclareProviderSpecsRoutes(apiRoutes, dep.ProviderSpecs)
DeclareRegistrationRoutes(apiRoutes, dep.AuthUser, dep.CaptchaVerifier)
@ -99,7 +106,17 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.Engine, dep Dependencies)
apiAuthRoutes.Use(middleware.AuthRequired())
DeclareAuthenticationCheckRoutes(apiAuthRoutes, lc)
DeclareDomainRoutes(apiAuthRoutes, dep.Domain, dep.DomainLog, dep.RemoteZoneImporter, dep.ZoneImporter, dep.Zone, dep.ZoneCorrectionApplier, dep.ZoneService, dep.Service)
DeclareDomainRoutes(
apiAuthRoutes,
dep.Domain,
dep.DomainLog,
dep.RemoteZoneImporter,
dep.ZoneImporter,
dep.Zone,
dep.ZoneCorrectionApplier,
dep.ZoneService,
dep.Service,
)
DeclareProviderRoutes(apiAuthRoutes, dep.Provider)
DeclareProviderSettingsRoutes(apiAuthRoutes, dep.ProviderSettings)
DeclareRecordRoutes(apiAuthRoutes)

View file

@ -29,15 +29,22 @@ import (
"git.happydns.org/happyDomain/model"
)
func DeclareZoneServiceRoutes(apiZonesRoutes, apiZonesSubdomainRoutes *gin.RouterGroup, zc *controller.ZoneController, zoneServiceUC happydns.ZoneServiceUsecase, serviceUC happydns.ServiceUsecase, zoneUC happydns.ZoneUsecase) {
func DeclareZoneServiceRoutes(
apiZonesRoutes,
apiZonesSubdomainRoutes *gin.RouterGroup,
zc *controller.ZoneController,
zoneServiceUC happydns.ZoneServiceUsecase,
serviceUC happydns.ServiceUsecase,
zoneUC happydns.ZoneUsecase,
) {
sc := controller.NewServiceController(zoneServiceUC, serviceUC, zoneUC)
apiZonesRoutes.PATCH("", sc.UpdateZoneService)
apiZonesSubdomainRoutes.POST("/services", sc.AddZoneService)
apiZonesSubdomainServiceIdRoutes := apiZonesSubdomainRoutes.Group("/services/:serviceid")
apiZonesSubdomainServiceIdRoutes.Use(middleware.ServiceIdHandler(serviceUC))
apiZonesSubdomainServiceIdRoutes.GET("", sc.GetZoneService)
apiZonesSubdomainServiceIdRoutes.DELETE("", sc.DeleteZoneService)
apiZonesSubdomainServiceIDRoutes := apiZonesSubdomainRoutes.Group("/services/:serviceid")
apiZonesSubdomainServiceIDRoutes.Use(middleware.ServiceIdHandler(serviceUC))
apiZonesSubdomainServiceIDRoutes.GET("", sc.GetZoneService)
apiZonesSubdomainServiceIDRoutes.DELETE("", sc.DeleteZoneService)
}

View file

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

View file

@ -29,7 +29,14 @@ import (
happydns "git.happydns.org/happyDomain/model"
)
func DeclareZoneRoutes(router *gin.RouterGroup, zoneUC happydns.ZoneUsecase, domainUC happydns.DomainUsecase, zoneCorrApplier happydns.ZoneCorrectionApplierUsecase, zoneServiceUC happydns.ZoneServiceUsecase, serviceUC happydns.ServiceUsecase) {
func DeclareZoneRoutes(
router *gin.RouterGroup,
zoneUC happydns.ZoneUsecase,
domainUC happydns.DomainUsecase,
zoneCorrApplier happydns.ZoneCorrectionApplierUsecase,
zoneServiceUC happydns.ZoneServiceUsecase,
serviceUC happydns.ServiceUsecase,
) {
zc := controller.NewZoneController(
zoneUC,
domainUC,
@ -44,13 +51,21 @@ func DeclareZoneRoutes(router *gin.RouterGroup, zoneUC happydns.ZoneUsecase, dom
apiZonesRoutes.POST("/diff/:oldzoneid", zc.DiffZonesHandler, zc.DiffZones)
apiZonesRoutes.POST("/diff/:oldzoneid/summary", zc.DiffZonesHandler, zc.DiffZonesSummary)
apiZonesRoutes.POST("/view", zc.ExportZone)
apiZonesRoutes.POST("/prepare_changes", zc.PrepareZoneCorrections)
apiZonesRoutes.POST("/apply_changes", zc.ApplyZoneCorrections)
apiZonesSubdomainRoutes := apiZonesRoutes.Group("/:subdomain")
apiZonesSubdomainRoutes.Use(middleware.SubdomainHandler)
apiZonesSubdomainRoutes.GET("", zc.GetZoneSubdomain)
DeclareZoneServiceRoutes(apiZonesRoutes, apiZonesSubdomainRoutes, zc, zoneServiceUC, serviceUC, zoneUC)
DeclareZoneServiceRoutes(
apiZonesRoutes,
apiZonesSubdomainRoutes,
zc,
zoneServiceUC,
serviceUC,
zoneUC,
)
apiZonesRoutes.POST("/records", zc.AddRecords)
apiZonesRoutes.POST("/records/delete", zc.DeleteRecords)

View file

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

View file

@ -76,7 +76,7 @@ type App struct {
cfg *happydns.Options
failureTracker *captcha.FailureTracker
insights *insightsCollector
mailer *mailer.Mailer
mailer happydns.Mailer
newsletter happydns.NewsletterSubscriptor
router *gin.Engine
srv *http.Server
@ -84,7 +84,6 @@ type App struct {
usecases Usecases
}
func NewApp(cfg *happydns.Options) *App {
app := &App{
cfg: cfg,
@ -94,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()
@ -109,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()
@ -129,19 +134,22 @@ func (app *App) initCaptcha() {
func (app *App) initMailer() {
if app.cfg.MailSMTPHost != "" {
app.mailer = &mailer.Mailer{
m := &mailer.Mailer{
MailFrom: &app.cfg.MailFrom,
SendMethod: mailer.NewSMTPMailer(app.cfg.MailSMTPHost, app.cfg.MailSMTPPort, app.cfg.MailSMTPUsername, app.cfg.MailSMTPPassword),
}
if app.cfg.MailSMTPTLSSNoVerify {
app.mailer.SendMethod.(*mailer.SMTPMailer).WithTLSNoVerify()
m.SendMethod.(*mailer.SMTPMailer).WithTLSNoVerify()
}
app.mailer = m
} else if !app.cfg.NoMail {
app.mailer = &mailer.Mailer{
MailFrom: &app.cfg.MailFrom,
SendMethod: &mailer.SystemSendmail{},
}
} else {
app.mailer = &mailer.LogMailer{}
}
}
@ -167,7 +175,7 @@ func (app *App) initNewsletter() {
if app.cfg.ListmonkURL.String() != "" {
app.newsletter = &newsletter.ListmonkNewsletterSubscription{
ListmonkURL: &app.cfg.ListmonkURL,
ListmonkId: app.cfg.ListmonkId,
ListmonkID: app.cfg.ListmonkID,
}
} else {
app.newsletter = &newsletter.DummyNewsletterSubscription{}
@ -186,27 +194,48 @@ func (app *App) initInsights() {
func (app *App) initUsecases() {
sessionService := sessionUC.NewService(app.store)
authUserService := authuserUC.NewAuthUserUsecases(app.cfg, app.mailer, app.store, sessionService)
authUserService := authuserUC.NewAuthUserUsecases(
app.cfg,
app.mailer,
app.store,
sessionService,
)
domainLogService := domainlogUC.NewService(app.store)
providerService := providerUC.NewRestrictedService(app.cfg, app.store)
providerAdminService := providerUC.NewService(app.store)
providerAdminService := providerUC.NewService(app.store, nil)
serviceService := serviceUC.NewServiceUsecases()
zoneService := zoneUC.NewZoneUsecases(app.store, serviceService)
app.usecases.providerSpecs = usecase.NewProviderSpecsUsecase()
app.usecases.provider = providerService
app.usecases.providerAdmin = providerAdminService
app.usecases.providerSettings = usecase.NewProviderSettingsUsecase(app.cfg, app.usecases.provider, app.store)
app.usecases.providerSettings = usecase.NewProviderSettingsUsecase(app.cfg, app.usecases.provider)
app.usecases.service = serviceService
app.usecases.serviceSpecs = usecase.NewServiceSpecsUsecase()
app.usecases.zone = zoneService
app.usecases.domainLog = domainLogService
domainService := domainUC.NewService(app.store, providerAdminService, zoneService.GetZoneUC, providerAdminService, domainLogService)
domainService := domainUC.NewService(
app.store,
providerAdminService,
zoneService.GetZoneUC,
providerAdminService,
domainLogService,
)
app.usecases.domain = domainService
app.usecases.zoneService = zoneServiceUC.NewZoneServiceUsecases(domainService, zoneService.CreateZoneUC, serviceService.ValidateServiceUC, app.store)
app.usecases.zoneService = zoneServiceUC.NewZoneServiceUsecases(
domainService,
zoneService.CreateZoneUC,
serviceService.ValidateServiceUC,
app.store,
)
app.usecases.user = userUC.NewUserUsecases(app.store, app.newsletter, authUserService, sessionService)
app.usecases.user = userUC.NewUserUsecases(
app.store,
app.newsletter,
authUserService,
sessionService,
)
app.usecases.authentication = usecase.NewAuthenticationUsecase(app.cfg, app.store, app.usecases.user)
app.usecases.authUser = authUserService
app.usecases.resolver = usecase.NewResolverUsecase(app.cfg)
@ -219,6 +248,7 @@ func (app *App) initUsecases() {
zoneService.ListRecordsUC,
providerAdminService,
zoneService.CreateZoneUC,
zoneService.GetZoneUC,
providerAdminService,
zoneService.UpdateZoneUC,
)
@ -236,28 +266,41 @@ func (app *App) setupRouter() {
session.NewSessionStore(app.cfg, app.store, []byte(app.cfg.JWTSecretKey)),
))
api.DeclareRoutes(app.cfg, app.router, api.Dependencies{
Authentication: app.usecases.authentication,
AuthUser: app.usecases.authUser,
CaptchaVerifier: app.captchaVerifier,
Domain: app.usecases.domain,
DomainLog: app.usecases.domainLog,
FailureTracker: app.failureTracker,
Provider: app.usecases.provider,
ProviderSettings: app.usecases.providerSettings,
ProviderSpecs: app.usecases.providerSpecs,
RemoteZoneImporter: app.usecases.orchestrator.RemoteZoneImporter,
Resolver: app.usecases.resolver,
Service: app.usecases.service,
ServiceSpecs: app.usecases.serviceSpecs,
Session: app.usecases.session,
User: app.usecases.user,
Zone: app.usecases.zone,
ZoneCorrectionApplier: app.usecases.orchestrator.ZoneCorrectionApplier,
ZoneImporter: app.usecases.orchestrator.ZoneImporter,
ZoneService: app.usecases.zoneService,
})
web.DeclareRoutes(app.cfg, app.router, app.captchaVerifier)
if len(app.cfg.BasePath) > 0 {
app.router.GET("/", func(c *gin.Context) {
c.Redirect(http.StatusFound, app.cfg.BasePath)
})
}
baserouter := app.router.Group(app.cfg.BasePath)
api.DeclareRoutes(
app.cfg,
baserouter,
api.Dependencies{
Authentication: app.usecases.authentication,
AuthUser: app.usecases.authUser,
CaptchaVerifier: app.captchaVerifier,
Domain: app.usecases.domain,
DomainLog: app.usecases.domainLog,
FailureTracker: app.failureTracker,
Provider: app.usecases.provider,
ProviderSettings: app.usecases.providerSettings,
ProviderSpecs: app.usecases.providerSpecs,
RemoteZoneImporter: app.usecases.orchestrator.RemoteZoneImporter,
Resolver: app.usecases.resolver,
Service: app.usecases.service,
ServiceSpecs: app.usecases.serviceSpecs,
Session: app.usecases.session,
User: app.usecases.user,
Zone: app.usecases.zone,
ZoneCorrectionApplier: app.usecases.orchestrator.ZoneCorrectionApplier,
ZoneImporter: app.usecases.orchestrator.ZoneImporter,
ZoneService: app.usecases.zoneService,
},
)
web.DeclareRoutes(app.cfg, baserouter, app.captchaVerifier)
web.NoRoute(app.cfg, app.router)
}
func (app *App) Start() {

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

@ -0,0 +1,184 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package app
import (
"fmt"
"log"
"os"
"path/filepath"
"plugin"
"git.happydns.org/happyDomain/checks"
"git.happydns.org/happyDomain/model"
"git.happydns.org/happyDomain/providers"
"git.happydns.org/happyDomain/services"
)
// pluginLoader attempts to find and register one specific kind of plugin
// symbol from an already-opened .so file.
//
// It returns (true, nil) when the symbol was found and registration
// succeeded, (true, err) when the symbol was found but something went wrong,
// and (false, nil) when the symbol simply isn't present in that file (which
// is not considered an error — a single .so may implement only a subset of
// the known plugin types).
type pluginLoader func(p *plugin.Plugin, fname string) (found bool, err error)
// pluginLoaders is the authoritative list of plugin types that happyDomain
// knows about. To support a new plugin type, add a single entry here.
var pluginLoaders = []pluginLoader{
loadCheckPlugin,
loadProviderPlugin,
loadServicePlugin,
}
// loadCheckPlugin handles the NewTestPlugin symbol.
func loadCheckPlugin(p *plugin.Plugin, fname string) (bool, error) {
sym, err := p.Lookup("NewCheckPlugin")
if err != nil {
// Symbol not present in this .so — not an error.
return false, nil
}
factory, ok := sym.(func() (string, happydns.Checker, error))
if !ok {
return true, fmt.Errorf("symbol NewCheckPlugin has unexpected type %T", sym)
}
pluginname, myplugin, err := factory()
if err != nil {
return true, err
}
checks.RegisterChecker(pluginname, myplugin)
log.Printf("Plugin %s loaded", pluginname)
return true, nil
}
// loadProviderPlugin handles the NewProviderPlugin symbol.
func loadProviderPlugin(_ *PluginManager, p *plugin.Plugin, fname string) (bool, error) {
sym, err := p.Lookup("NewProviderPlugin")
if err != nil {
// Symbol not present in this .so — not an error.
return false, nil
}
factory, ok := sym.(func() (happydns.ProviderCreatorFunc, happydns.ProviderInfos, error))
if !ok {
return true, fmt.Errorf("symbol NewProviderPlugin has unexpected type %T", sym)
}
creator, infos, err := factory()
if err != nil {
return true, err
}
providers.RegisterProvider(creator, infos)
log.Printf("Plugin provider %q registered from %s", infos.Name, fname)
return true, nil
}
// loadServicePlugin handles the NewServicePlugin symbol.
func loadServicePlugin(_ *PluginManager, p *plugin.Plugin, fname string) (bool, error) {
sym, err := p.Lookup("NewServicePlugin")
if err != nil {
// Symbol not present in this .so — not an error.
return false, nil
}
factory, ok := sym.(func() (happydns.ServiceCreator, svcs.ServiceAnalyzer, happydns.ServiceInfos, uint32, []string, error))
if !ok {
return true, fmt.Errorf("symbol NewServicePlugin has unexpected type %T", sym)
}
creator, analyzer, infos, weight, aliases, err := factory()
if err != nil {
return true, err
}
svcs.RegisterService(creator, analyzer, infos, weight, aliases...)
log.Printf("Plugin service %q registered from %s", infos.Name, fname)
return true, nil
}
// initPlugins scans each directory listed in cfg.PluginsDirectories, loads
// every .so file found as a Go plugin, and registers it in the application's
// PluginManager. All load errors are collected and returned as a joined error
// so that a single bad plugin does not prevent the others from loading.
func (a *App) initPlugins() error {
for _, directory := range a.cfg.PluginsDirectories {
files, err := os.ReadDir(directory)
if err != nil {
return fmt.Errorf("unable to read plugins directory %q: %s", directory, err)
}
for _, file := range files {
if file.IsDir() {
continue
}
// Only attempt to load shared-object files.
if filepath.Ext(file.Name()) != ".so" {
continue
}
fname := filepath.Join(directory, file.Name())
err = loadPlugin(fname)
if err != nil {
log.Printf("Unable to load plugin %q: %s", fname, err)
}
}
}
return nil
}
// loadPlugin opens the .so file at fname and runs every registered
// pluginLoader against it. A loader that does not find its symbol is silently
// skipped. If no loader recognises any symbol in the file a warning is logged,
// but no error is returned because the file might be a valid plugin for a
// future version of happyDomain. The first loader error that is encountered
// is returned immediately.
func loadPlugin(fname string) error {
p, err := plugin.Open(fname)
if err != nil {
return err
}
anyFound := false
for _, loader := range pluginLoaders {
found, err := loader(p, fname)
if err != nil {
return err
}
if found {
anyFound = true
}
}
if !anyFound {
log.Printf("Warning: plugin %q exports no recognised symbols", fname)
}
return nil
}

View file

@ -47,7 +47,7 @@ func declareFlags(o *happydns.Options) {
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.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")
flag.IntVar(&o.ListmonkID, "newsletter-id", 1, "Listmonk identifier of the list receiving the new user")
flag.BoolVar(&o.NoMail, "no-mail", o.NoMail, "Disable all automatic mails, skip email verification at registration")
flag.Var(&mailAddress{&o.MailFrom}, "mail-from", "Define the sender name and address for all e-mail sent")
@ -60,6 +60,8 @@ func declareFlags(o *happydns.Options) {
flag.StringVar(&o.CaptchaProvider, "captcha-provider", o.CaptchaProvider, "Captcha provider to use for bot protection (altcha, hcaptcha, recaptchav2, turnstile, or empty to disable)")
flag.IntVar(&o.CaptchaLoginThreshold, "captcha-login-threshold", 3, "Number of failed login attempts before captcha is required (0 = always require when provider configured)")
flag.Var(&ArrayArgs{&o.PluginsDirectories}, "plugins-directory", "Path to a directory containing plugins (can be repeated multiple times)")
// Others flags are declared in some other files likes sources, storages, ... when they need specials configurations
}

View file

@ -62,7 +62,7 @@ func ConsolidateConfig() (opts *happydns.Options, err error) {
"happydomain.conf",
}
if home, err := os.UserConfigDir(); err == nil {
if home, dirErr := os.UserConfigDir(); dirErr == nil {
configLocations = append(configLocations, path.Join(home, "happydomain", "happydomain.conf"))
}
@ -98,6 +98,10 @@ func ConsolidateConfig() (opts *happydns.Options, err error) {
} else {
opts.BasePath = ""
}
if opts.DevProxy != "" && opts.BasePath != "" {
err = fmt.Errorf("-base-path is not supported in -dev mode")
return
}
if opts.NoMail && opts.MailSMTPHost != "" {
err = fmt.Errorf("-no-mail and -mail-smtp-* cannot be defined at the same time")
@ -143,17 +147,17 @@ func ConsolidateConfig() (opts *happydns.Options, err error) {
// parseLine treats a config line and place the read value in the variable
// declared to the corresponding flag.
func parseLine(o *happydns.Options, line string) (err error) {
func parseLine(_ *happydns.Options, line string) (err error) {
fields := strings.SplitN(line, "=", 2)
orig_key := strings.TrimSpace(fields[0])
origKey := strings.TrimSpace(fields[0])
value := strings.TrimSpace(fields[1])
if len(value) == 0 {
return
}
key := strings.TrimPrefix(strings.TrimPrefix(orig_key, "HAPPYDNS_"), "HAPPYDOMAIN_")
key = strings.Replace(key, "_", "-", -1)
key := strings.TrimPrefix(strings.TrimPrefix(origKey, "HAPPYDNS_"), "HAPPYDOMAIN_")
key = strings.ReplaceAll(key, "_", "-")
key = strings.ToLower(key)
err = flag.Set(key, value)

View file

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

View file

@ -44,9 +44,8 @@ func GenField(field reflect.StructField) (f *happydns.Field) {
}
tag := field.Tag.Get("happydomain")
tuples := strings.Split(tag, ",")
for _, t := range tuples {
for t := range strings.SplitSeq(tag, ",") {
kv := strings.SplitN(t, "=", 2)
if len(kv) > 1 {
switch strings.ToLower(kv[0]) {
@ -80,7 +79,7 @@ func GenField(field reflect.StructField) (f *happydns.Field) {
}
// GenStructFields generates corresponding SourceFields of the given Source.
func GenStructFields(data interface{}) (fields []*happydns.Field) {
func GenStructFields(data any) (fields []*happydns.Field) {
if data != nil {
dataMeta := reflect.Indirect(reflect.ValueOf(data)).Type()

View file

@ -25,7 +25,7 @@ import (
"git.happydns.org/happyDomain/model"
)
func DoSettingState(fu happydns.FormUsecase, state *happydns.FormState, data interface{}, defaultForm func(interface{}) *happydns.CustomForm) (form *happydns.CustomForm, d map[string]interface{}, err error) {
func DoSettingState(fu happydns.FormUsecase, state *happydns.FormState, data any, defaultForm func(any) *happydns.CustomForm) (form *happydns.CustomForm, d map[string]any, err error) {
if csf, ok := data.(happydns.CustomSettingsForm); ok {
return csf.DisplaySettingsForm(state.State, func() string {
return state.Recall

View file

@ -26,7 +26,7 @@ import (
)
// GenDefaultSettingsForm generates a generic CustomForm presenting all the fields in one page.
func GenDefaultSettingsForm(data interface{}) *happydns.CustomForm {
func GenDefaultSettingsForm(data any) *happydns.CustomForm {
return &happydns.CustomForm{
Fields: GenStructFields(data),
NextButtonText: "common.create-thing",

View file

@ -46,7 +46,7 @@ func GeneratePassword() (password string, err error) {
// This one is to avoid problem with openssl
{"/", "^"},
} {
password = strings.Replace(password, i[0], i[1], -1)
password = strings.ReplaceAll(password, i[0], i[1])
}
return

View file

@ -41,7 +41,7 @@ func TestGeneratePassword(t *testing.T) {
t.Run("password does not contain replaced characters", func(t *testing.T) {
forbiddenChars := []string{"v", "u", "l", "1", "o", "O", "0", "/"}
for i := 0; i < 100; i++ {
for range 100 {
password, err := GeneratePassword()
if err != nil {
t.Fatalf("GeneratePassword() returned error: %v", err)
@ -59,7 +59,7 @@ func TestGeneratePassword(t *testing.T) {
replacementChars := []string{"*", "(", "%", "?", "@", "!", ">", "^"}
foundChars := make(map[string]bool)
for i := 0; i < 1000; i++ {
for range 1000 {
password, err := GeneratePassword()
if err != nil {
t.Fatalf("GeneratePassword() returned error: %v", err)
@ -81,7 +81,7 @@ func TestGeneratePassword(t *testing.T) {
passwords := make(map[string]bool)
iterations := 100
for i := 0; i < iterations; i++ {
for range iterations {
password, err := GeneratePassword()
if err != nil {
t.Fatalf("GeneratePassword() returned error: %v", err)
@ -98,7 +98,7 @@ func TestGeneratePassword(t *testing.T) {
t.Run("password uses valid characters", func(t *testing.T) {
validChars := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+*(%?@!>^="
for i := 0; i < 100; i++ {
for range 100 {
password, err := GeneratePassword()
if err != nil {
t.Fatalf("GeneratePassword() returned error: %v", err)
@ -116,7 +116,7 @@ func TestGeneratePassword(t *testing.T) {
charCounts := make(map[rune]int)
iterations := 1000
for i := 0; i < iterations; i++ {
for range iterations {
password, err := GeneratePassword()
if err != nil {
t.Fatalf("GeneratePassword() returned error: %v", err)
@ -134,7 +134,7 @@ func TestGeneratePassword(t *testing.T) {
})
t.Run("password ends with valid character", func(t *testing.T) {
for i := 0; i < 100; i++ {
for range 100 {
password, err := GeneratePassword()
if err != nil {
t.Fatalf("GeneratePassword() returned error: %v", err)
@ -162,7 +162,7 @@ func TestGeneratePasswordNonEmpty(t *testing.T) {
func TestGeneratePasswordConsistentLength(t *testing.T) {
lengths := make(map[int]int)
for i := 0; i < 1000; i++ {
for range 1000 {
password, err := GeneratePassword()
if err != nil {
t.Fatalf("GeneratePassword() returned error: %v", err)
@ -194,7 +194,7 @@ func TestGeneratePasswordReplacements(t *testing.T) {
}
t.Run("verifies character replacements", func(t *testing.T) {
for i := 0; i < 100; i++ {
for range 100 {
password, err := GeneratePassword()
if err != nil {
t.Fatalf("GeneratePassword() returned error: %v", err)
@ -210,7 +210,7 @@ func TestGeneratePasswordReplacements(t *testing.T) {
}
func BenchmarkGeneratePassword(b *testing.B) {
for i := 0; i < b.N; i++ {
for b.Loop() {
_, err := GeneratePassword()
if err != nil {
b.Fatalf("GeneratePassword() returned error: %v", err)

View file

@ -33,7 +33,7 @@ func GenUsername(email string) (toName string) {
toName = email[0:strings.Index(email, "@")]
}
if len(toName) > 1 {
toNameCopy := strings.Replace(toName, ".", " ", -1)
toNameCopy := strings.ReplaceAll(toName, ".", " ")
toName = ""
lastRuneIsSpace := true
for _, runeValue := range toNameCopy {

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

@ -0,0 +1,36 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package mailer
import (
"log"
"net/mail"
)
// LogMailer is a dummy mailer that prints emails to stdout.
// It is used when no real mail transport is configured.
type LogMailer struct{}
func (l *LogMailer) SendMail(to *mail.Address, subject, content string) error {
log.Printf("--- Mail to %s ---\nSubject: %s\n\n%s\n--- End of mail ---", to.String(), subject, content)
return nil
}

View file

@ -70,13 +70,13 @@ func (r *Mailer) SendMail(to *mail.Address, subject, content string) (err error)
"Content": content,
}
if t, err := template.New("mailText").Parse(mailTXTTpl); err != nil {
t, err := template.New("mailText").Parse(mailTXTTpl)
if err != nil {
return err
} else {
m.SetBodyWriter("text/plain", func(w io.Writer) error {
return t.Execute(w, tplData)
})
}
m.SetBodyWriter("text/plain", func(w io.Writer) error {
return t.Execute(w, tplData)
})
// Convert text from Markdown to HTML
md := goldmark.New(
@ -95,18 +95,18 @@ func (r *Mailer) SendMail(to *mail.Address, subject, content string) (err error)
return
}
if data, err := web.GetEmbedFS().Open("build/img/happydomain.png"); err == nil {
if data, imgErr := web.GetEmbedFS().Open("build/img/happydomain.png"); imgErr == nil {
m.EmbedReader("happydomain.png", data)
}
if t, err := template.New("mailHTML").Parse(mailHTMLTpl); err != nil {
t, err = template.New("mailHTML").Parse(mailHTMLTpl)
if err != nil {
return err
} else {
m.AddAlternativeWriter("text/html", func(w io.Writer) error {
tplData["Content"] = buf.String()
return t.Execute(w, tplData)
})
}
m.AddAlternativeWriter("text/html", func(w io.Writer) error {
tplData["Content"] = buf.String()
return t.Execute(w, tplData)
})
if err = r.SendMethod.PrepareAndSend(m); err != nil {
return

View file

@ -65,7 +65,7 @@ func (t *SystemSendmail) Send(from string, to []string, msg io.WriterTo) error {
return nil
}
// Send sends an e-mail to the given recipients using the sendmail command.
// PrepareAndSend sends an e-mail to the given recipients using the sendmail command.
func (t *SystemSendmail) PrepareAndSend(m ...*gomail.Message) (err error) {
err = gomail.Send(t, m...)
return

View file

@ -51,7 +51,7 @@ func (t *SMTPMailer) WithTLSNoVerify() {
}
}
// Send sends an e-mail to the given recipients using configured SMTP host.
// PrepareAndSend sends an e-mail to the given recipients using configured SMTP host.
func (t *SMTPMailer) PrepareAndSend(m ...*gomail.Message) (err error) {
err = t.Dialer.DialAndSend(m...)

View file

@ -36,20 +36,20 @@ import (
type ListmonkNewsletterSubscription struct {
ListmonkURL *url.URL
ListmonkId int
ListmonkID int
}
type ListmonkSubscriber struct {
Email string `json:"email"`
Name string `json:"name"`
Status string `json:"status,omitempty"`
Lists []int `json:"lists"`
Attribs map[string]interface{} `json:"attribs,omitempty"`
PreconfirmSubscriptions bool `json:"preconfirm_subscriptions,omitempty"`
Email string `json:"email"`
Name string `json:"name"`
Status string `json:"status,omitempty"`
Lists []int `json:"lists"`
Attribs map[string]any `json:"attribs,omitempty"`
PreconfirmSubscriptions bool `json:"preconfirm_subscriptions,omitempty"`
}
func (ns *ListmonkNewsletterSubscription) SubscribeToNewsletter(u happydns.UserInfo) error {
if ns.ListmonkId == 0 {
if ns.ListmonkID == 0 {
log.Println("SubscribeToNewsletter: not subscribing user as newsletter list id is not defined.")
return nil
}
@ -61,7 +61,7 @@ func (ns *ListmonkNewsletterSubscription) SubscribeToNewsletter(u happydns.UserI
Email: u.GetEmail(),
Name: helpers.GenUsername(u.GetEmail()),
Status: "enabled",
Lists: []int{ns.ListmonkId},
Lists: []int{ns.ListmonkID},
PreconfirmSubscriptions: true,
}

View file

@ -50,7 +50,7 @@ func NewSessionStore(opts *happydns.Options, storage sessionUC.SessionStorage, k
Codecs: securecookie.CodecsFromPairs(keyPairs...),
options: &sessions.Options{
Path: opts.BasePath + "/",
MaxAge: 86400 * 30,
MaxAge: int(sessionUC.SESSION_MAX_DURATION.Seconds()),
Secure: opts.DevProxy == "" && opts.ExternalURL.Scheme != "http",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
@ -75,9 +75,13 @@ func (s *SessionStore) New(r *http.Request, name string) (*sessions.Session, err
if _, ok := r.Header["Authorization"]; ok && len(r.Header["Authorization"]) > 0 {
if flds := strings.Fields(r.Header["Authorization"][0]); len(flds) == 2 && flds[0] == "Bearer" {
session.ID = flds[1]
if isValidSessionID(flds[1]) {
session.ID = flds[1]
}
} else if user, _, ok := r.BasicAuth(); ok {
session.ID = user
if isValidSessionID(user) {
session.ID = user
}
}
} else if cookie, err := r.Cookie(name); err == nil {
err := securecookie.DecodeMulti(name, cookie.Value, &session.ID, s.Codecs...)
@ -93,7 +97,9 @@ func (s *SessionStore) New(r *http.Request, name string) (*sessions.Session, err
}
err := s.load(session)
session.IsNew = false
if err == nil {
session.IsNew = false
}
return session, err
}
@ -102,7 +108,9 @@ func (s *SessionStore) Save(r *http.Request, w http.ResponseWriter, session *ses
var cookieValue string
if s.options.MaxAge < 0 || session.Options.MaxAge < 0 {
s.storage.DeleteSession(session.ID)
if err := s.storage.DeleteSession(session.ID); err != nil {
return err
}
} else {
if session.ID == "" {
session.ID = sessionUC.NewSessionID()
@ -163,6 +171,11 @@ func (s *SessionStore) load(session *sessions.Session) error {
session.Values["created_on"] = mysession.IssuedAt
}
if !mysession.ExpiresOn.IsZero() {
if mysession.ExpiresOn.Before(time.Now()) {
// Session has expired; delete it and treat this as a new session.
_ = s.storage.DeleteSession(session.ID)
return fmt.Errorf("session has expired")
}
session.Values["expires_on"] = mysession.ExpiresOn
}
@ -202,11 +215,12 @@ func (s *SessionStore) save(session *sessions.Session, ua string) error {
}
if exOn == nil {
expiresOn = time.Now().Add(time.Second * time.Duration(session.Options.MaxAge))
expiresOn = time.Now().Add(sessionUC.SESSION_MAX_DURATION)
} else {
expiresOn = exOn.(time.Time)
if expiresOn.Sub(time.Now().Add(time.Second*time.Duration(session.Options.MaxAge))) < 0 {
expiresOn = time.Now().Add(time.Second * time.Duration(session.Options.MaxAge))
// Auto-renew if the session expires within the renewal window.
if time.Until(expiresOn) < sessionUC.SESSION_RENEWAL_THRESHOLD {
expiresOn = time.Now().Add(sessionUC.SESSION_MAX_DURATION)
}
}
@ -227,3 +241,17 @@ func (s *SessionStore) save(session *sessions.Session, ua string) error {
return s.storage.UpdateSession(mysession)
}
// isValidSessionID returns true if s looks like a session ID generated by
// NewSessionID: base32 standard alphabet ([A-Z2-7]), exactly 103 characters.
func isValidSessionID(s string) bool {
if len(s) != 103 {
return false
}
for _, c := range s {
if !((c >= 'A' && c <= 'Z') || (c >= '2' && c <= '7')) {
return false
}
}
return true
}

View file

@ -94,7 +94,7 @@ func (s *InMemoryStorage) Close() error {
}
// DecodeData decodes data from the interface (expected to be []byte) into v.
func (s *InMemoryStorage) DecodeData(data interface{}, v interface{}) error {
func (s *InMemoryStorage) DecodeData(data any, v any) error {
b, ok := data.([]byte)
if !ok {
return fmt.Errorf("data to decode are not in []byte format (%T)", data)
@ -111,7 +111,7 @@ func (s *InMemoryStorage) Has(key string) (bool, error) {
}
// Get retrieves a value by key and decodes it into v.
func (s *InMemoryStorage) Get(key string, v interface{}) error {
func (s *InMemoryStorage) Get(key string, v any) error {
s.mu.Lock()
defer s.mu.Unlock()
data, exists := s.data[key]
@ -122,7 +122,7 @@ func (s *InMemoryStorage) Get(key string, v interface{}) error {
}
// Put stores a value with the given key.
func (s *InMemoryStorage) Put(key string, v interface{}) error {
func (s *InMemoryStorage) Put(key string, v any) error {
data, err := json.Marshal(v)
if err != nil {
return err

View file

@ -57,7 +57,7 @@ func (it *KVIterator) Key() string {
}
// Value returns the current value.
func (it *KVIterator) Value() interface{} {
func (it *KVIterator) Value() any {
if it.index < 0 || it.index >= len(it.keys) {
return []byte{}
}

View file

@ -63,15 +63,15 @@ type Iterator interface {
Next() bool
Valid() bool
Key() string
Value() interface{}
Value() any
}
type KVStorage interface {
Close() error
DecodeData(i interface{}, v interface{}) error
DecodeData(i any, v any) error
Has(key string) (bool, error)
Get(key string, v interface{}) error
Put(key string, v interface{}) error
Get(key string, v any) error
Put(key string, v any) error
FindIdentifierKey(prefix string) (key string, id happydns.Identifier, err error)
Delete(key string) error
Search(prefix string) Iterator

View file

@ -95,7 +95,7 @@ func (it *KVIterator[T]) DropItem() error {
// Raw returns the raw (non-decoded) value at the current iterator position.
// Should only be called after a successful call to Next().
func (it *KVIterator[T]) Raw() interface{} {
func (it *KVIterator[T]) Raw() any {
if it.iter == nil || !it.iter.Valid() {
return []byte{}
}

View file

@ -38,8 +38,8 @@ import (
// abstract.EMail
func explodeAbstractEMail(dn happydns.Subdomain, in *happydns.ServiceMessage) ([]*happydns.ServiceMessage, error) {
var val struct {
MX []map[string]interface{} `json:"mx,omitempty"`
SPF map[string]interface{} `json:"spf,omitempty"`
MX []map[string]any `json:"mx,omitempty"`
SPF map[string]any `json:"spf,omitempty"`
DKIM map[string]*svcs.DKIM `json:"dkim,omitempty"`
DMARC *svcs.DMARCFields `json:"dmarc,omitempty"`
MTA_STS *svcs.MTASTSFields `json:"mta_sts,omitempty"`
@ -56,11 +56,12 @@ func explodeAbstractEMail(dn happydns.Subdomain, in *happydns.ServiceMessage) ([
if len(val.MX) > 0 {
var mxs svcs.MXs
var rr dns.RR
for _, mx := range val.MX {
if _, ok := mx["preference"]; !ok {
mx["preference"] = 0.0
}
rr, err := dns.NewRR(fmt.Sprintf("zZzZ. 0 IN MX %.0f %s", mx["preference"].(float64), helpers.DomainFQDN(mx["target"].(string), "zZzZ.")))
rr, err = dns.NewRR(fmt.Sprintf("zZzZ. 0 IN MX %.0f %s", mx["preference"].(float64), helpers.DomainFQDN(mx["target"].(string), "zZzZ.")))
if err != nil {
return nil, err
} else {
@ -82,8 +83,8 @@ func explodeAbstractEMail(dn happydns.Subdomain, in *happydns.ServiceMessage) ([
}
if val.SPF != nil {
if _, ok := val.SPF["directives"].([]interface{}); ok {
directives := val.SPF["directives"].([]interface{})
if _, ok := val.SPF["directives"].([]any); ok {
directives := val.SPF["directives"].([]any)
var dir []string
for _, directive := range directives {
dir = append(dir, directive.(string))
@ -191,12 +192,12 @@ func explodeAbstractEMail(dn happydns.Subdomain, in *happydns.ServiceMessage) ([
var migrateFrom7SvcType map[string]func(json.RawMessage) (json.RawMessage, error)
func migrateFrom7(s *KVStorage) (err error) {
func migrateFrom7(s *KVStorage) error {
migrateFrom7SvcType = make(map[string]func(json.RawMessage) (json.RawMessage, error))
// abstract.ACMEChallenge
migrateFrom7SvcType["abstract.ACMEChallenge"] = func(in json.RawMessage) (json.RawMessage, error) {
val := map[string]interface{}{}
val := map[string]any{}
err := json.Unmarshal(in, &val)
if err != nil {
@ -218,7 +219,7 @@ func migrateFrom7(s *KVStorage) (err error) {
// abstract.Delegation
migrateFrom7SvcType["abstract.Delegation"] = func(in json.RawMessage) (json.RawMessage, error) {
val := map[string]interface{}{}
val := map[string]any{}
err := json.Unmarshal(in, &val)
if err != nil {
@ -227,7 +228,7 @@ func migrateFrom7(s *KVStorage) (err error) {
var delegation abstract.Delegation
if nss, ok := val["ns"].([]interface{}); ok {
if nss, ok := val["ns"].([]any); ok {
for _, ns := range nss {
rr, err := dns.NewRR(fmt.Sprintf("zZzZ. 0 IN NS %s", helpers.DomainFQDN(ns.(string), "zZzZ")))
if err != nil {
@ -239,9 +240,9 @@ func migrateFrom7(s *KVStorage) (err error) {
}
}
if dss, ok := val["ds"].([]interface{}); ok {
if dss, ok := val["ds"].([]any); ok {
for _, dsI := range dss {
ds := dsI.(map[string]interface{})
ds := dsI.(map[string]any)
rr, err := dns.NewRR(fmt.Sprintf("zZzZ. 0 IN DS %.0f %.0f %.0f %s", ds["keytag"].(float64), ds["algorithm"].(float64), ds["digestType"].(float64), ds["digest"].(string)))
if err != nil {
return nil, err
@ -257,7 +258,7 @@ func migrateFrom7(s *KVStorage) (err error) {
// abstract.GithubOrgVerif
migrateFrom7SvcType["abstract.GithubOrgVerif"] = func(in json.RawMessage) (json.RawMessage, error) {
val := map[string]interface{}{}
val := map[string]any{}
err := json.Unmarshal(in, &val)
if err != nil {
@ -279,7 +280,7 @@ func migrateFrom7(s *KVStorage) (err error) {
// abstract.GitlabPageVerif
migrateFrom7SvcType["abstract.GitlabPageVerif"] = func(in json.RawMessage) (json.RawMessage, error) {
val := map[string]interface{}{}
val := map[string]any{}
err := json.Unmarshal(in, &val)
if err != nil {
@ -301,7 +302,7 @@ func migrateFrom7(s *KVStorage) (err error) {
// abstract.GoogleVerif
migrateFrom7SvcType["abstract.GoogleVerif"] = func(in json.RawMessage) (json.RawMessage, error) {
val := map[string]interface{}{}
val := map[string]any{}
err := json.Unmarshal(in, &val)
if err != nil {
@ -323,7 +324,7 @@ func migrateFrom7(s *KVStorage) (err error) {
// abstract.KeybaseVerif
migrateFrom7SvcType["abstract.KeybaseVerif"] = func(in json.RawMessage) (json.RawMessage, error) {
val := map[string]interface{}{}
val := map[string]any{}
err := json.Unmarshal(in, &val)
if err != nil {
@ -345,7 +346,7 @@ func migrateFrom7(s *KVStorage) (err error) {
// abstract.MatrixIM
migrateFrom7SvcType["abstract.MatrixIM"] = func(in json.RawMessage) (json.RawMessage, error) {
val := map[string]interface{}{}
val := map[string]any{}
err := json.Unmarshal(in, &val)
if err != nil {
@ -354,9 +355,9 @@ func migrateFrom7(s *KVStorage) (err error) {
var matrix abstract.MatrixIM
if mat, ok := val["matrix"].([]interface{}); ok {
if mat, ok := val["matrix"].([]any); ok {
for _, mI := range mat {
m := mI.(map[string]interface{})
m := mI.(map[string]any)
rr, err := dns.NewRR(fmt.Sprintf("_matrix._tcp.zZzZ. 0 IN SRV %.0f %.0f %.0f %s", m["priority"].(float64), m["weight"].(float64), m["port"].(float64), helpers.DomainFQDN(m["target"].(string), "zZzZ.")))
if err != nil {
return nil, err
@ -372,7 +373,7 @@ func migrateFrom7(s *KVStorage) (err error) {
// abstract.OpenPGP
migrateFrom7SvcType["abstract.OpenPGP"] = func(in json.RawMessage) (json.RawMessage, error) {
val := map[string]interface{}{}
val := map[string]any{}
err := json.Unmarshal(in, &val)
if err != nil {
@ -397,7 +398,7 @@ func migrateFrom7(s *KVStorage) (err error) {
// abstract.SMimeCert
migrateFrom7SvcType["abstract.SMimeCert"] = func(in json.RawMessage) (json.RawMessage, error) {
val := map[string]interface{}{}
val := map[string]any{}
err := json.Unmarshal(in, &val)
if err != nil {
@ -422,7 +423,7 @@ func migrateFrom7(s *KVStorage) (err error) {
// abstract.Origin
migrateFrom7SvcType["abstract.Origin"] = func(in json.RawMessage) (json.RawMessage, error) {
val := map[string]interface{}{}
val := map[string]any{}
err := json.Unmarshal(in, &val)
if err != nil {
@ -439,8 +440,8 @@ func migrateFrom7(s *KVStorage) (err error) {
origin.SOA = helpers.RRRelative(rr, "zZzZ").(*dns.SOA)
}
if _, ok := val["ns"].([]interface{}); ok {
for _, nsI := range val["ns"].([]interface{}) {
if _, ok := val["ns"].([]any); ok {
for _, nsI := range val["ns"].([]any) {
rr, err := dns.NewRR(fmt.Sprintf("zZzZ. 0 IN NS %s", helpers.DomainFQDN(nsI.(string), "zZzZ.")))
if err != nil {
return nil, err
@ -456,7 +457,7 @@ func migrateFrom7(s *KVStorage) (err error) {
// abstract.NSOnlyOrigin
migrateFrom7SvcType["abstract.NSOnlyOrigin"] = func(in json.RawMessage) (json.RawMessage, error) {
val := map[string]interface{}{}
val := map[string]any{}
err := json.Unmarshal(in, &val)
if err != nil {
@ -465,8 +466,8 @@ func migrateFrom7(s *KVStorage) (err error) {
var origin abstract.NSOnlyOrigin
if _, ok := val["ns"].([]interface{}); ok {
for _, nsI := range val["ns"].([]interface{}) {
if _, ok := val["ns"].([]any); ok {
for _, nsI := range val["ns"].([]any) {
rr, err := dns.NewRR(fmt.Sprintf("zZzZ. 0 IN NS %s", helpers.DomainFQDN(nsI.(string), "zZzZ.")))
if err != nil {
return nil, err
@ -482,7 +483,7 @@ func migrateFrom7(s *KVStorage) (err error) {
// abstract.RFC6186
migrateFrom7SvcType["abstract.RFC6186"] = func(in json.RawMessage) (json.RawMessage, error) {
val := map[string]interface{}{}
val := map[string]any{}
err := json.Unmarshal(in, &val)
if err != nil {
@ -491,9 +492,9 @@ func migrateFrom7(s *KVStorage) (err error) {
var rfc6186 abstract.RFC6186
if _, ok := val["submission"].([]interface{}); ok {
for _, clientI := range val["submission"].([]interface{}) {
client := clientI.(map[string]interface{})
if _, ok := val["submission"].([]any); ok {
for _, clientI := range val["submission"].([]any) {
client := clientI.(map[string]any)
rr, err := dns.NewRR(fmt.Sprintf("%s.zZzZ. 0 IN SRV %.0f %.0f %.0f %s", helpers.DomainJoin("_submission", "_tcp"), client["priority"].(float64), client["weight"].(float64), client["port"].(float64), helpers.DomainFQDN(client["target"].(string), "zZzZ.")))
if err != nil {
return nil, err
@ -503,9 +504,9 @@ func migrateFrom7(s *KVStorage) (err error) {
}
}
}
if _, ok := val["imaps"].([]interface{}); ok {
for _, clientI := range val["imaps"].([]interface{}) {
client := clientI.(map[string]interface{})
if _, ok := val["imaps"].([]any); ok {
for _, clientI := range val["imaps"].([]any) {
client := clientI.(map[string]any)
rr, err := dns.NewRR(fmt.Sprintf("%s.zZzZ. 0 IN SRV %.0f %.0f %.0f %s", helpers.DomainJoin("_imaps", "_tcp"), client["priority"].(float64), client["weight"].(float64), client["port"].(float64), helpers.DomainFQDN(client["target"].(string), "zZzZ.")))
if err != nil {
return nil, err
@ -515,9 +516,9 @@ func migrateFrom7(s *KVStorage) (err error) {
}
}
}
if _, ok := val["pop3s"].([]interface{}); ok {
for _, clientI := range val["pop3s"].([]interface{}) {
client := clientI.(map[string]interface{})
if _, ok := val["pop3s"].([]any); ok {
for _, clientI := range val["pop3s"].([]any) {
client := clientI.(map[string]any)
rr, err := dns.NewRR(fmt.Sprintf("%s.zZzZ. 0 IN SRV %.0f %.0f %.0f %s", helpers.DomainJoin("_pop3s", "_tcp"), client["priority"].(float64), client["weight"].(float64), client["port"].(float64), helpers.DomainFQDN(client["target"].(string), "zZzZ.")))
if err != nil {
return nil, err
@ -527,9 +528,9 @@ func migrateFrom7(s *KVStorage) (err error) {
}
}
}
if _, ok := val["submissions"].([]interface{}); ok {
for _, clientI := range val["submissions"].([]interface{}) {
client := clientI.(map[string]interface{})
if _, ok := val["submissions"].([]any); ok {
for _, clientI := range val["submissions"].([]any) {
client := clientI.(map[string]any)
rr, err := dns.NewRR(fmt.Sprintf("%s.zZzZ. 0 IN SRV %.0f %.0f %.0f %s", helpers.DomainJoin("_submissions", "_tcp"), client["priority"].(float64), client["weight"].(float64), client["port"].(float64), helpers.DomainFQDN(client["target"].(string), "zZzZ.")))
if err != nil {
return nil, err
@ -539,9 +540,9 @@ func migrateFrom7(s *KVStorage) (err error) {
}
}
}
if _, ok := val["imap"].([]interface{}); ok {
for _, clientI := range val["imap"].([]interface{}) {
client := clientI.(map[string]interface{})
if _, ok := val["imap"].([]any); ok {
for _, clientI := range val["imap"].([]any) {
client := clientI.(map[string]any)
rr, err := dns.NewRR(fmt.Sprintf("%s.zZzZ. 0 IN SRV %.0f %.0f %.0f %s", helpers.DomainJoin("_imap", "_tcp"), client["priority"].(float64), client["weight"].(float64), client["port"].(float64), helpers.DomainFQDN(client["target"].(string), "zZzZ.")))
if err != nil {
return nil, err
@ -551,9 +552,9 @@ func migrateFrom7(s *KVStorage) (err error) {
}
}
}
if _, ok := val["pop3"].([]interface{}); ok {
for _, clientI := range val["pop3"].([]interface{}) {
client := clientI.(map[string]interface{})
if _, ok := val["pop3"].([]any); ok {
for _, clientI := range val["pop3"].([]any) {
client := clientI.(map[string]any)
rr, err := dns.NewRR(fmt.Sprintf("%s.zZzZ. 0 IN SRV %.0f %.0f %.0f %s", helpers.DomainJoin("_pop3", "_tcp"), client["priority"].(float64), client["weight"].(float64), client["port"].(float64), helpers.DomainFQDN(client["target"].(string), "zZzZ.")))
if err != nil {
return nil, err
@ -569,7 +570,7 @@ func migrateFrom7(s *KVStorage) (err error) {
// abstract.ScalewayChallenge
migrateFrom7SvcType["abstract.ScalewayChallenge"] = func(in json.RawMessage) (json.RawMessage, error) {
val := map[string]interface{}{}
val := map[string]any{}
err := json.Unmarshal(in, &val)
if err != nil {
@ -591,7 +592,7 @@ func migrateFrom7(s *KVStorage) (err error) {
// abstract.Server
migrateFrom7SvcType["abstract.Server"] = func(in json.RawMessage) (json.RawMessage, error) {
val := map[string]interface{}{}
val := map[string]any{}
err := json.Unmarshal(in, &val)
if err != nil {
@ -620,9 +621,9 @@ func migrateFrom7(s *KVStorage) (err error) {
}
}
if _, ok := val["SSHFP"].([]interface{}); ok {
for _, sshfpI := range val["SSHFP"].([]interface{}) {
sshfp := sshfpI.(map[string]interface{})
if _, ok := val["SSHFP"].([]any); ok {
for _, sshfpI := range val["SSHFP"].([]any) {
sshfp := sshfpI.(map[string]any)
rr, err := dns.NewRR(fmt.Sprintf("zZzZ. 0 IN SSHFP %.0f %.0f %s", sshfp["algorithm"], sshfp["type"], sshfp["fingerprint"]))
if err != nil {
return nil, err
@ -638,7 +639,7 @@ func migrateFrom7(s *KVStorage) (err error) {
// abstract.XMPP
migrateFrom7SvcType["abstract.XMPP"] = func(in json.RawMessage) (json.RawMessage, error) {
val := map[string]interface{}{}
val := map[string]any{}
err := json.Unmarshal(in, &val)
if err != nil {
@ -647,9 +648,9 @@ func migrateFrom7(s *KVStorage) (err error) {
var xmpp abstract.XMPP
if _, ok := val["Client"].([]interface{}); ok {
for _, clientI := range val["Client"].([]interface{}) {
client := clientI.(map[string]interface{})
if _, ok := val["Client"].([]any); ok {
for _, clientI := range val["Client"].([]any) {
client := clientI.(map[string]any)
rr, err := dns.NewRR(fmt.Sprintf("_xmpp-client._tcp.zZzZ. 0 IN SRV %.0f %.0f %.0f %s", client["priority"], client["weight"], client["port"], helpers.DomainFQDN(client["target"].(string), "zZzZ.")))
if err != nil {
return nil, err
@ -660,9 +661,9 @@ func migrateFrom7(s *KVStorage) (err error) {
}
}
if _, ok := val["Server"].([]interface{}); ok {
for _, serverI := range val["Server"].([]interface{}) {
server := serverI.(map[string]interface{})
if _, ok := val["Server"].([]any); ok {
for _, serverI := range val["Server"].([]any) {
server := serverI.(map[string]any)
rr, err := dns.NewRR(fmt.Sprintf("_xmpp-server._tcp.zZzZ. 0 IN SRV %.0f %.0f %.0f %s", server["priority"], server["weight"], server["port"], helpers.DomainFQDN(server["target"].(string), "zZzZ.")))
if err != nil {
return nil, err
@ -673,9 +674,9 @@ func migrateFrom7(s *KVStorage) (err error) {
}
}
if _, ok := val["Jabber"].([]interface{}); ok {
for _, jabberI := range val["Jabber"].([]interface{}) {
jabber := jabberI.(map[string]interface{})
if _, ok := val["Jabber"].([]any); ok {
for _, jabberI := range val["Jabber"].([]any) {
jabber := jabberI.(map[string]any)
rr, err := dns.NewRR(fmt.Sprintf("_jabber._tcp.zZzZ. 0 IN SRV %.0f %.0f %.0f %s", jabber["priority"], jabber["weight"], jabber["port"], helpers.DomainFQDN(jabber["target"].(string), "zZzZ.")))
if err != nil {
return nil, err
@ -841,7 +842,7 @@ func migrateFrom7(s *KVStorage) (err error) {
// svcs.MXs
migrateFrom7SvcType["svcs.MXs"] = func(in json.RawMessage) (json.RawMessage, error) {
val := map[string]interface{}{}
val := map[string]any{}
err := json.Unmarshal(in, &val)
if err != nil {
@ -850,8 +851,8 @@ func migrateFrom7(s *KVStorage) (err error) {
var mxs svcs.MXs
for _, mxI := range val["mx"].([]interface{}) {
mx := mxI.(map[string]interface{})
for _, mxI := range val["mx"].([]any) {
mx := mxI.(map[string]any)
if _, ok := mx["preference"]; !ok {
mx["preference"] = 0.0
}
@ -868,14 +869,14 @@ func migrateFrom7(s *KVStorage) (err error) {
// svcs.SPF
migrateFrom7SvcType["svcs.SPF"] = func(in json.RawMessage) (json.RawMessage, error) {
val := map[string]interface{}{}
val := map[string]any{}
err := json.Unmarshal(in, &val)
if err != nil {
return nil, err
}
directives := val["directives"].([]interface{})
directives := val["directives"].([]any)
var dir []string
for _, directive := range directives {
dir = append(dir, directive.(string))
@ -891,7 +892,7 @@ func migrateFrom7(s *KVStorage) (err error) {
// svcs.TLSAs
migrateFrom7SvcType["svcs.TLSAs"] = func(in json.RawMessage) (json.RawMessage, error) {
val := map[string]interface{}{}
val := map[string]any{}
err := json.Unmarshal(in, &val)
if err != nil {
@ -900,8 +901,8 @@ func migrateFrom7(s *KVStorage) (err error) {
var tlsa svcs.TLSAs
for _, tlsaI := range val["tlsa"].([]interface{}) {
t := tlsaI.(map[string]interface{})
for _, tlsaI := range val["tlsa"].([]any) {
t := tlsaI.(map[string]any)
rr, err := dns.NewRR(fmt.Sprintf("%s 0 IN TLSA %.0f %.0f %.0f %s", helpers.DomainJoin(fmt.Sprintf("_%.0f._%s.zZzZ.", t["port"].(float64), t["proto"].(string))), t["certusage"].(float64), t["selector"].(float64), t["matchingtype"].(float64), t["certificate"].(string)))
if err != nil {
return nil, err
@ -967,7 +968,7 @@ func migrateFrom7(s *KVStorage) (err error) {
// svcs.UnknownSRV
migrateFrom7SvcType["svcs.UnknownSRV"] = func(in json.RawMessage) (json.RawMessage, error) {
val := map[string]interface{}{}
val := map[string]any{}
err := json.Unmarshal(in, &val)
if err != nil {
@ -976,9 +977,9 @@ func migrateFrom7(s *KVStorage) (err error) {
var usrv svcs.UnknownSRV
if mat, ok := val["srv"].([]interface{}); ok {
if mat, ok := val["srv"].([]any); ok {
for _, mI := range mat {
m := mI.(map[string]interface{})
m := mI.(map[string]any)
rr, err := dns.NewRR(fmt.Sprintf("%s 0 IN SRV %.0f %.0f %.0f %s", helpers.DomainJoin("_"+val["name"].(string), "_"+val["proto"].(string), "zZzZ"), m["priority"].(float64), m["weight"].(float64), m["port"].(float64), helpers.DomainFQDN(m["target"].(string), "zZzZ.")))
if err != nil {
return nil, err

View file

@ -32,12 +32,12 @@ import (
"git.happydns.org/happyDomain/services/providers/google"
)
func migrateFrom8(s *KVStorage) (err error) {
func migrateFrom8(s *KVStorage) error {
migrateFrom7SvcType = make(map[string]func(json.RawMessage) (json.RawMessage, error))
// google.GSuite
migrateFrom7SvcType["google.GSuite"] = func(in json.RawMessage) (json.RawMessage, error) {
val := map[string]interface{}{}
val := map[string]any{}
err := json.Unmarshal(in, &val)
if err != nil {

View file

@ -65,11 +65,11 @@ func (s *LevelDBStorage) Close() error {
return s.db.Close()
}
func decodeData(data []byte, v interface{}) error {
func decodeData(data []byte, v any) error {
return json.Unmarshal(data, v)
}
func (s *LevelDBStorage) DecodeData(data interface{}, v interface{}) error {
func (s *LevelDBStorage) DecodeData(data any, v any) error {
b, ok := data.([]byte)
if !ok {
return fmt.Errorf("data to decode are not in []byte format (%T)", data)
@ -81,7 +81,7 @@ func (s *LevelDBStorage) Has(key string) (bool, error) {
return s.db.Has([]byte(key), nil)
}
func (s *LevelDBStorage) Get(key string, v interface{}) error {
func (s *LevelDBStorage) Get(key string, v any) error {
data, err := s.db.Get([]byte(key), nil)
if err != nil {
if goerrors.Is(err, leveldb.ErrNotFound) {
@ -93,7 +93,7 @@ func (s *LevelDBStorage) Get(key string, v interface{}) error {
return decodeData(data, v)
}
func (s *LevelDBStorage) Put(key string, v interface{}) error {
func (s *LevelDBStorage) Put(key string, v any) error {
data, err := json.Marshal(v)
if err != nil {
return err

View file

@ -43,7 +43,7 @@ func (it *LevelDBIterator) Key() string {
return string(it.iter.Key())
}
func (it *LevelDBIterator) Value() interface{} {
func (it *LevelDBIterator) Value() any {
return it.iter.Value()
}

View file

@ -78,11 +78,11 @@ func (s *NoSQLStorage) Close() error {
return s.client.Close()
}
func (n *NoSQLStorage) DecodeData(data interface{}, v interface{}) error {
func (n *NoSQLStorage) DecodeData(data any, v any) error {
return json.Unmarshal([]byte(jsonutil.AsJSON(data)), v)
}
func (n *NoSQLStorage) Get(key string, v interface{}) error {
func (n *NoSQLStorage) Get(key string, v any) error {
gkey := &types.MapValue{}
gkey.Put("key", key)
@ -108,7 +108,7 @@ func (n *NoSQLStorage) Get(key string, v interface{}) error {
return n.DecodeData(data, v)
}
func (n *NoSQLStorage) Put(key string, v interface{}) error {
func (n *NoSQLStorage) Put(key string, v any) error {
data, err := json.Marshal(v)
if err != nil {
return fmt.Errorf("unable to marshal data: %w", err)

View file

@ -94,7 +94,7 @@ func (i *Iterator) Key() string {
return key.(string)
}
func (i *Iterator) Value() interface{} {
func (i *Iterator) Value() any {
value, _ := i.results[i.cur_result].Get("value")
return value
}

View file

@ -119,7 +119,7 @@ func (s *PostgreSQLStorage) Close() error {
return nil
}
func (s *PostgreSQLStorage) DecodeData(data interface{}, v interface{}) error {
func (s *PostgreSQLStorage) DecodeData(data any, v any) error {
var bytes []byte
switch d := data.(type) {
@ -146,7 +146,7 @@ func (s *PostgreSQLStorage) Has(key string) (bool, error) {
return exists, nil
}
func (s *PostgreSQLStorage) Get(key string, v interface{}) error {
func (s *PostgreSQLStorage) Get(key string, v any) error {
query := fmt.Sprintf("SELECT data FROM %s WHERE key = $1", s.table)
var jsonData []byte
@ -161,7 +161,7 @@ func (s *PostgreSQLStorage) Get(key string, v interface{}) error {
return json.Unmarshal(jsonData, v)
}
func (s *PostgreSQLStorage) Put(key string, v interface{}) error {
func (s *PostgreSQLStorage) Put(key string, v any) error {
// Marshal value to JSON
jsonData, err := json.Marshal(v)
if err != nil {

View file

@ -86,6 +86,6 @@ func (it *PostgreSQLIterator) Key() string {
}
// Value returns the current value as []byte
func (it *PostgreSQLIterator) Value() interface{} {
func (it *PostgreSQLIterator) Value() any {
return it.value
}

View file

@ -35,7 +35,7 @@ func (i *StorageEngine) String() string {
func (i *StorageEngine) Set(value string) (err error) {
if _, ok := StorageEngines[value]; !ok {
return fmt.Errorf("Unexistant storage engine: please select one between: %v", GetStorageEngines())
return fmt.Errorf("unexistant storage engine: please select one between: %v", GetStorageEngines())
}
*i = StorageEngine(value)
return nil

View file

@ -122,7 +122,7 @@ func Test_AuthenticateUserWithPassword_WrongPassword(t *testing.T) {
Email: "a@b.c",
Password: "wrong-password",
})
if err == nil || err.Error() != `tries to login as "a@b.c", but sent an invalid password` {
if err == nil || err.Error() != `invalid password` {
t.Errorf("unexpected error: %v", err)
}
}
@ -151,7 +151,7 @@ func Test_AuthenticateUserWithPassword_WeakPassword(t *testing.T) {
Email: "a@b.c",
Password: "weak",
})
if err == nil || err.Error() != `tries to login as "a@b.c", but sent an invalid password` {
if err == nil || err.Error() != `invalid password` {
t.Errorf("unexpected error: %v", err)
}
}
@ -180,7 +180,7 @@ func Test_AuthenticateUserWithPassword_UnverifiedEmail(t *testing.T) {
Email: "a@b.c",
Password: "v3rySecure",
})
if err == nil || err.Error() != `tries to login as "a@b.c", but has not verified email` {
if err == nil || err.Error() != `account email not verified` {
t.Errorf("unexpected error: %v", err)
}
}

View file

@ -25,6 +25,8 @@ import (
"fmt"
"time"
"golang.org/x/crypto/bcrypt"
"git.happydns.org/happyDomain/internal/usecase/authuser"
"git.happydns.org/happyDomain/internal/usecase/user"
"git.happydns.org/happyDomain/model"
@ -77,17 +79,28 @@ func (lu *loginUsecase) AuthenticateUserWithPassword(request happydns.LoginReque
// Retrieve the given user
user, err := lu.store.GetAuthUserByEmail(request.Email)
if err != nil {
return nil, fmt.Errorf("user's email (%s) not found: %s", request.Email, err.Error())
// Perform a dummy bcrypt comparison to equalize timing with the
// valid-user path and prevent email enumeration via response time.
bcrypt.CompareHashAndPassword([]byte("$2a$12$dummy.hash.that.never.matches.any.real.password.value"), []byte(request.Password))
return nil, fmt.Errorf("user not found: %w", err)
}
if !user.CheckPassword(request.Password) {
return nil, fmt.Errorf("tries to login as %q, but sent an invalid password", request.Email)
return nil, fmt.Errorf("invalid password")
}
// Ensure the account is enabled
if !lu.config.NoMail && user.EmailVerification == nil {
return nil, fmt.Errorf("tries to login as %q, but has not verified email", request.Email)
return nil, fmt.Errorf("account email not verified")
}
// Record the successful login time and transparently upgrade the hash cost if needed
now := time.Now()
user.LastLoggedIn = &now
if user.NeedsRehash() {
user.DefinePassword(request.Password)
}
lu.store.UpdateAuthUser(user)
return lu.CompleteAuthentication(user)
}

View file

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

View file

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

View file

@ -0,0 +1,27 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Package authuser groups all use cases related to authenticated user
// management: registration, password changes, email validation, account
// recovery, and deletion. The Service type is the main entry point; it
// composes EmailValidationUsecase and RecoverAccountUsecase for their
// respective sub-workflows.
package authuser

View file

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

View file

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

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

@ -0,0 +1,28 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Package usecase implements the top-level application use cases for
// happyDomain. Each file wires together lower-level domain services to fulfil
// a single business capability: user authentication, DNS resolution, provider
// settings wizard, and database tidy-up. These usecases are consumed directly
// by the HTTP/API layer and delegate persistence to the storage interfaces
// defined in the sub-packages.
package usecase

View file

@ -0,0 +1,27 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Package domain implements the use cases for managing DNS domains in
// happyDomain. It covers the full lifecycle of a domain: creation (including
// existence verification against the provider), retrieval, update, and
// deletion. It also exposes helpers for enriching a domain with its zone
// metadata history.
package domain

View file

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

View file

@ -0,0 +1,28 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Package domainlog implements the use cases for domain audit-log management.
// Each significant action performed on a domain (import, publish, error) is
// recorded as a DomainLog entry. The Service type exposes CRUD operations on
// those entries, returning them sorted by date (newest first). The
// DomainLogAppender interface is intentionally narrow so other packages can
// append log entries without depending on the full Service.
package domainlog

View file

@ -0,0 +1,37 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package domainlog
import (
"log"
"git.happydns.org/happyDomain/model"
)
// NoopDomainLogAppender is a fallback implementation of DomainLogAppender
// that prints log entries to stdout instead of persisting them.
type NoopDomainLogAppender struct{}
func (NoopDomainLogAppender) AppendDomainLog(domain *happydns.Domain, entry *happydns.DomainLog) error {
log.Printf("domain=%s %s\n", domain.DomainName, entry.Content)
return nil
}

View file

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

View file

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

View file

@ -0,0 +1,27 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Package insight collects anonymous usage statistics about a running
// happyDomain instance. The Collect function aggregates counters from the
// storage layer (number of users, providers, domains, zones) together with
// build metadata and runtime configuration flags into a single Insights
// value that can be reported to the telemetry endpoint.
package insight

View file

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

View file

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

View file

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

View file

@ -0,0 +1,102 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package orchestrator
import (
"context"
adapter "git.happydns.org/happyDomain/internal/adapters"
zoneUC "git.happydns.org/happyDomain/internal/usecase/zone"
"git.happydns.org/happyDomain/model"
)
// ZoneCorrectionListerUsecase computes the list of corrections needed to
// synchronize a zone's desired state with the records currently published by
// the provider. It fetches provider records, expands the WIP zone to records,
// and computes a local diff without executable closures.
type ZoneCorrectionListerUsecase struct {
providerService ProviderGetter
listRecords *zoneUC.ListRecordsUsecase
zoneCorrector ZoneCorrector
zoneRetriever ZoneRetriever
}
// NewZoneCorrectionListerUsecase creates a ZoneCorrectionListerUsecase with
// the given provider getter, record lister, zone corrector, and zone retriever.
func NewZoneCorrectionListerUsecase(
providerService ProviderGetter,
listRecords *zoneUC.ListRecordsUsecase,
zoneCorrector ZoneCorrector,
zoneRetriever ZoneRetriever,
) *ZoneCorrectionListerUsecase {
return &ZoneCorrectionListerUsecase{
providerService: providerService,
listRecords: listRecords,
zoneCorrector: zoneCorrector,
zoneRetriever: zoneRetriever,
}
}
// listWithRecords is the internal implementation that returns the corrections
// along with the provider and WIP records used to compute them.
func (uc *ZoneCorrectionListerUsecase) listWithRecords(
ctx context.Context,
user *happydns.User,
domain *happydns.Domain,
zone *happydns.Zone,
) ([]*happydns.Correction, []happydns.Record, []happydns.Record, int, error) {
provider, err := uc.providerService.GetUserProvider(user, domain.ProviderId)
if err != nil {
return nil, nil, nil, 0, err
}
providerRecords, err := uc.zoneRetriever.RetrieveZone(ctx, provider, domain.DomainName)
if err != nil {
return nil, nil, nil, 0, err
}
wipRecords, err := uc.listRecords.List(domain, zone)
if err != nil {
return nil, nil, nil, 0, err
}
corrections, nbDiffs, err := adapter.DNSControlDiffByRecord(providerRecords, wipRecords, domain.DomainName)
if err != nil {
return nil, nil, nil, nbDiffs, err
}
return corrections, providerRecords, wipRecords, nbDiffs, nil
}
// List returns the corrections required to bring the provider's live DNS
// records in line with the given zone. It fetches the current provider
// records, expands the zone into individual records, and computes the diff
// locally. The second return value is the total number of corrections.
func (uc *ZoneCorrectionListerUsecase) List(
ctx context.Context,
user *happydns.User,
domain *happydns.Domain,
zone *happydns.Zone,
) ([]*happydns.Correction, int, error) {
corrections, _, _, nbDiffs, err := uc.listWithRecords(ctx, user, domain, zone)
return corrections, nbDiffs, err
}

View file

@ -0,0 +1,188 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package orchestrator_test
import (
"context"
"errors"
"testing"
"git.happydns.org/happyDomain/internal/usecase/orchestrator"
serviceUC "git.happydns.org/happyDomain/internal/usecase/service"
zoneUC "git.happydns.org/happyDomain/internal/usecase/zone"
"git.happydns.org/happyDomain/model"
)
// mockProviderGetter implements ProviderGetter for testing.
type mockProviderGetter struct {
provider *happydns.Provider
err error
}
func (m *mockProviderGetter) GetUserProvider(_ *happydns.User, _ happydns.Identifier) (*happydns.Provider, error) {
return m.provider, m.err
}
// mockZoneCorrector implements ZoneCorrector for testing.
type mockZoneCorrector struct {
corrections []*happydns.Correction
nbDiff int
err error
}
func (m *mockZoneCorrector) ListZoneCorrections(_ context.Context, _ *happydns.Provider, _ *happydns.Domain, _ []happydns.Record) ([]*happydns.Correction, int, error) {
return m.corrections, m.nbDiff, m.err
}
// mockZoneRetriever implements ZoneRetriever for testing.
type mockZoneRetriever struct {
records []happydns.Record
err error
}
func (m *mockZoneRetriever) RetrieveZone(_ context.Context, _ *happydns.Provider, _ string) ([]happydns.Record, error) {
return m.records, m.err
}
func newTestListRecordsUsecase() *zoneUC.ListRecordsUsecase {
return zoneUC.NewListRecordsUsecase(serviceUC.NewListRecordsUsecase())
}
func TestZoneCorrectionLister_List_Success(t *testing.T) {
provider := &happydns.Provider{}
uc := orchestrator.NewZoneCorrectionListerUsecase(
&mockProviderGetter{provider: provider},
newTestListRecordsUsecase(),
&mockZoneCorrector{},
&mockZoneRetriever{records: nil},
)
user := &happydns.User{Id: happydns.Identifier([]byte("test-user"))}
domain := &happydns.Domain{
Id: happydns.Identifier([]byte("test-domain")),
ProviderId: happydns.Identifier([]byte("test-provider")),
DomainName: "example.com.",
}
zone := &happydns.Zone{
ZoneMeta: happydns.ZoneMeta{DefaultTTL: 3600},
Services: map[happydns.Subdomain][]*happydns.Service{},
}
got, nbDiff, err := uc.List(context.Background(), user, domain, zone)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if nbDiff != 0 {
t.Errorf("expected nbDiff=0, got %d", nbDiff)
}
if len(got) != 0 {
t.Errorf("expected 0 corrections, got %d", len(got))
}
}
func TestZoneCorrectionLister_List_ProviderError(t *testing.T) {
providerErr := errors.New("provider not found")
uc := orchestrator.NewZoneCorrectionListerUsecase(
&mockProviderGetter{err: providerErr},
newTestListRecordsUsecase(),
&mockZoneCorrector{},
&mockZoneRetriever{},
)
user := &happydns.User{Id: happydns.Identifier([]byte("test-user"))}
domain := &happydns.Domain{
ProviderId: happydns.Identifier([]byte("missing-provider")),
DomainName: "example.com.",
}
zone := &happydns.Zone{
ZoneMeta: happydns.ZoneMeta{DefaultTTL: 3600},
}
_, _, err := uc.List(context.Background(), user, domain, zone)
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, providerErr) {
t.Errorf("expected %v, got %v", providerErr, err)
}
}
func TestZoneCorrectionLister_List_RetrieveZoneError(t *testing.T) {
retrieveErr := errors.New("zone retrieval failed")
uc := orchestrator.NewZoneCorrectionListerUsecase(
&mockProviderGetter{provider: &happydns.Provider{}},
newTestListRecordsUsecase(),
&mockZoneCorrector{},
&mockZoneRetriever{err: retrieveErr},
)
user := &happydns.User{Id: happydns.Identifier([]byte("test-user"))}
domain := &happydns.Domain{
ProviderId: happydns.Identifier([]byte("test-provider")),
DomainName: "example.com.",
}
zone := &happydns.Zone{
ZoneMeta: happydns.ZoneMeta{DefaultTTL: 3600},
Services: map[happydns.Subdomain][]*happydns.Service{},
}
_, _, err := uc.List(context.Background(), user, domain, zone)
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, retrieveErr) {
t.Errorf("expected %v, got %v", retrieveErr, err)
}
}
func TestZoneCorrectionLister_List_NoCorrections(t *testing.T) {
uc := orchestrator.NewZoneCorrectionListerUsecase(
&mockProviderGetter{provider: &happydns.Provider{}},
newTestListRecordsUsecase(),
&mockZoneCorrector{corrections: nil, nbDiff: 0},
&mockZoneRetriever{records: nil},
)
user := &happydns.User{Id: happydns.Identifier([]byte("test-user"))}
domain := &happydns.Domain{
ProviderId: happydns.Identifier([]byte("test-provider")),
DomainName: "example.com.",
}
zone := &happydns.Zone{
ZoneMeta: happydns.ZoneMeta{DefaultTTL: 3600},
Services: map[happydns.Subdomain][]*happydns.Service{},
}
got, nbDiff, err := uc.List(context.Background(), user, domain, zone)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if nbDiff != 0 {
t.Errorf("expected nbDiff=0, got %d", nbDiff)
}
if len(got) != 0 {
t.Errorf("expected 0 corrections, got %d", len(got))
}
}

View file

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

View file

@ -0,0 +1,30 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Package provider implements the use cases for DNS provider management.
// Service handles creation, retrieval, update, and deletion of providers,
// with ownership enforcement (a user may only access their own providers).
// RestrictedService wraps Service and enforces the DisableProviders
// configuration flag. Zone and domain operations (record retrieval,
// correction listing, domain existence checking) are provided as separate
// methods so they can be consumed by higher-level orchestration use cases
// without circular imports.
package provider

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,51 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Package session provides the business logic for managing user sessions in
// happyDomain. It exposes a [Service] that handles the full session lifecycle:
// creation, retrieval, update, and deletion, as well as bulk operations such as
// closing all sessions for a given user.
//
// The package defines the [SessionStorage] interface that any persistence
// backend must implement. A concrete implementation is injected at construction
// time via [NewService], keeping this layer free of storage concerns.
//
// Session identifiers are randomly generated, base32-encoded strings (see
// [NewSessionID]). Sessions carry an expiry timestamp and are automatically
// bound to a single user — cross-user access is rejected at the use-case level.
//
// Typical usage:
//
// svc := session.NewService(myStorageBackend)
//
// sess, err := svc.CreateUserSession(user, "browser login")
// // … store sess.Id in a cookie …
//
// sess, err = svc.GetUserSession(user, sessionID)
//
// err = svc.UpdateUserSession(user, sessionID, func(s *happydns.Session) {
// s.Description = "renamed"
// })
//
// err = svc.DeleteUserSession(user, sessionID)
//
// err = svc.CloseUserSessions(user) // invalidate all sessions at once
package session

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