feat(checker): add domain availability watchlist

Introduce a DomainAvailabilityWatch entity (model, storage, usecase and
REST endpoints) letting a user track a domain they do not own and get
notified the moment it becomes available for registration. A dedicated
domain_availability checker reads WHOIS/RDAP via pkg/domaininfo and inverts
the status (OK while registered, Crit once free) so the existing dispatcher
fires exactly once on the transition. The scheduler enumerates watches and
enqueues the check, carrying the watch id in CheckTarget.DomainId; autofill
and notification payloads fall back to the watch store to resolve the name.
Watches are included in per-user backup/restore. The web UI adds an
availability watchlist page and navigation entry.
This commit is contained in:
nemunaire 2026-05-29 18:31:55 +08:00
commit 5ccf81173f
30 changed files with 1423 additions and 109 deletions

View file

@ -0,0 +1,157 @@
// 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 checkers
import (
"context"
"errors"
"fmt"
"time"
"git.happydns.org/happyDomain/internal/dnschecker"
"git.happydns.org/happyDomain/model"
"git.happydns.org/happyDomain/pkg/domaininfo"
)
// ObservationKeyAvailability is the observation key for domain availability data.
const ObservationKeyAvailability happydns.ObservationKey = "availability"
// AvailabilityData represents domain availability observation data.
type AvailabilityData struct {
Available bool `json:"available"`
Registrar string `json:"registrar,omitempty"`
ExpiryDate *time.Time `json:"expiryDate,omitempty"`
}
// availabilityProvider collects whether a domain name is currently registered.
type availabilityProvider struct{}
func (p *availabilityProvider) Key() happydns.ObservationKey {
return ObservationKeyAvailability
}
func (p *availabilityProvider) Collect(ctx context.Context, opts happydns.CheckerOptions) (any, error) {
domainName, _ := opts["domainName"].(string)
if domainName == "" {
return nil, fmt.Errorf("domainName is required")
}
info, err := domaininfo.GetDomainInfo(ctx, happydns.Origin(domainName))
if err != nil {
if errors.Is(err, happydns.ErrDomainDoesNotExist) {
return &AvailabilityData{Available: true}, nil
}
return nil, fmt.Errorf("failed to retrieve domain info: %w", err)
}
return &AvailabilityData{
Available: false,
Registrar: info.Registrar,
ExpiryDate: info.ExpirationDate,
}, nil
}
// domainAvailabilityRule emits a notify-worthy status when a watched domain
// becomes available for registration. The status is inverted relative to the
// usual convention (Crit when available) so the registered->available
// transition crosses the notification threshold and the dispatcher fires once.
type domainAvailabilityRule struct{}
func (r *domainAvailabilityRule) Name() string {
return "domain_availability_check"
}
func (r *domainAvailabilityRule) Description() string {
return "Checks whether a watched domain name has become available for registration"
}
func (r *domainAvailabilityRule) ValidateOptions(opts happydns.CheckerOptions) error {
return nil
}
func (r *domainAvailabilityRule) Evaluate(ctx context.Context, obs happydns.ObservationGetter, opts happydns.CheckerOptions) []happydns.CheckState {
var data AvailabilityData
if err := obs.Get(ctx, ObservationKeyAvailability, &data); err != nil {
return []happydns.CheckState{{
Status: happydns.StatusError,
Message: fmt.Sprintf("Failed to get availability data: %v", err),
Code: "availability_error",
}}
}
domainName, _ := opts["domainName"].(string)
if data.Available {
return []happydns.CheckState{{
Status: happydns.StatusCrit,
Message: fmt.Sprintf("Domain %s is now available for registration", domainName),
Code: "available",
}}
}
meta := map[string]any{}
if data.Registrar != "" {
meta["registrar"] = data.Registrar
}
if data.ExpiryDate != nil {
meta["expiry_date"] = data.ExpiryDate
}
return []happydns.CheckState{{
Status: happydns.StatusOK,
Message: fmt.Sprintf("Domain %s is still registered", domainName),
Code: "registered",
Meta: meta,
}}
}
func init() {
dnschecker.RegisterObservationProvider(&availabilityProvider{})
dnschecker.RegisterChecker(&happydns.CheckerDefinition{
ID: "domain_availability",
Name: "Domain Availability",
// All Availability flags are left false so IsAutoScheduled never
// schedules this checker on managed domains. It is scheduled only via
// the dedicated availability-watch enumeration in the scheduler.
Availability: happydns.CheckerAvailability{},
ObservationKeys: []happydns.ObservationKey{ObservationKeyAvailability},
Options: happydns.CheckerOptionsDocumentation{
DomainOpts: []happydns.CheckerOptionDocumentation{
{
Id: "domainName",
Type: "string",
AutoFill: happydns.AutoFillDomainName,
Hide: true,
},
},
},
Rules: []happydns.CheckRule{
&domainAvailabilityRule{},
},
Interval: &happydns.CheckIntervalSpec{
Min: 1 * time.Hour,
Max: 24 * time.Hour,
Default: 6 * time.Hour,
},
})
}

View file

@ -0,0 +1,180 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controller
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/middleware"
"git.happydns.org/happyDomain/model"
)
type DomainAvailabilityWatchController struct {
watchService happydns.DomainAvailabilityWatchUsecase
}
func NewDomainAvailabilityWatchController(watchService happydns.DomainAvailabilityWatchUsecase) *DomainAvailabilityWatchController {
return &DomainAvailabilityWatchController{
watchService: watchService,
}
}
// ListDomainAvailabilityWatches retrieves all availability watches owned by the user.
//
// @Summary Retrieve user's availability watches
// @Schemes
// @Description Retrieve all domain availability watches belonging to the user.
// @Tags availability
// @Accept json
// @Produce json
// @Security securitydefinitions.basic
// @Success 200 {array} happydns.DomainAvailabilityWatch
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
// @Failure 500 {object} happydns.ErrorResponse "Unable to retrieve watches"
// @Router /availability [get]
func (wc *DomainAvailabilityWatchController) ListDomainAvailabilityWatches(c *gin.Context) {
user := middleware.MyUser(c)
if user == nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"errmsg": "User not defined"})
return
}
watches, err := wc.watchService.ListUserDomainAvailabilityWatches(user)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, watches)
}
// AddDomainAvailabilityWatch registers a new availability watch.
//
// @Summary Add a new availability watch
// @Schemes
// @Description Register a new domain availability watch for the user.
// @Tags availability
// @Accept json
// @Produce json
// @Param body body happydns.DomainAvailabilityWatchCreationInput true "Watch to add"
// @Security securitydefinitions.basic
// @Success 200 {object} happydns.DomainAvailabilityWatch
// @Failure 400 {object} happydns.ErrorResponse "Error in received data"
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
// @Failure 500 {object} happydns.ErrorResponse "Database writing error"
// @Router /availability [post]
func (wc *DomainAvailabilityWatchController) AddDomainAvailabilityWatch(c *gin.Context) {
user := middleware.MyUser(c)
if user == nil {
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("No user specified."))
return
}
var input happydns.DomainAvailabilityWatchCreationInput
if err := c.ShouldBindJSON(&input); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": fmt.Sprintf("Unable to decode given watch: %s", err.Error())})
return
}
watch, err := wc.watchService.CreateDomainAvailabilityWatch(c.Request.Context(), user, &input)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, watch)
}
// GetDomainAvailabilityWatch retrieves a single availability watch owned by the user.
//
// @Summary Retrieve an availability watch
// @Schemes
// @Description Retrieve a single domain availability watch owned by the user.
// @Tags availability
// @Accept json
// @Produce json
// @Param watchId path string true "Watch identifier"
// @Security securitydefinitions.basic
// @Success 200 {object} happydns.DomainAvailabilityWatch
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
// @Failure 404 {object} happydns.ErrorResponse "Watch not found"
// @Router /availability/{watchId} [get]
func (wc *DomainAvailabilityWatchController) GetDomainAvailabilityWatch(c *gin.Context) {
user := middleware.MyUser(c)
if user == nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"errmsg": "User not defined"})
return
}
id, err := happydns.NewIdentifierFromString(c.Param("watchId"))
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("Invalid watch identifier: %w", err))
return
}
watch, err := wc.watchService.GetUserDomainAvailabilityWatch(user, id)
if err != nil {
middleware.ErrorResponse(c, http.StatusNotFound, err)
return
}
c.JSON(http.StatusOK, watch)
}
// DeleteDomainAvailabilityWatch removes an availability watch owned by the user.
//
// @Summary Delete an availability watch
// @Schemes
// @Description Delete a domain availability watch owned by the user.
// @Tags availability
// @Accept json
// @Produce json
// @Param watchId path string true "Watch identifier"
// @Security securitydefinitions.basic
// @Success 204 "Watch deleted"
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
// @Failure 404 {object} happydns.ErrorResponse "Watch not found"
// @Failure 500 {object} happydns.ErrorResponse "Database writing error"
// @Router /availability/{watchId} [delete]
func (wc *DomainAvailabilityWatchController) DeleteDomainAvailabilityWatch(c *gin.Context) {
user := middleware.MyUser(c)
if user == nil {
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("User not defined."))
return
}
id, err := happydns.NewIdentifierFromString(c.Param("watchId"))
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("Invalid watch identifier: %w", err))
return
}
if err := wc.watchService.DeleteDomainAvailabilityWatch(user, id); err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.Status(http.StatusNoContent)
}

View file

@ -50,7 +50,7 @@ func ErrorResponse(c *gin.Context, defaultStatus int, err error) {
c.AbortWithStatusJSON(status, e.ToErrorResponse())
return
} else if errors.Is(err, happydns.ErrAuthUserNotFound) || errors.Is(err, happydns.ErrDomainNotFound) || errors.Is(err, happydns.ErrDomainLogNotFound) || errors.Is(err, happydns.ErrProviderNotFound) || errors.Is(err, happydns.ErrSessionNotFound) || errors.Is(err, happydns.ErrUserNotFound) || errors.Is(err, happydns.ErrUserAlreadyExist) || errors.Is(err, happydns.ErrZoneNotFound) {
} else if errors.Is(err, happydns.ErrAuthUserNotFound) || errors.Is(err, happydns.ErrDomainNotFound) || errors.Is(err, happydns.ErrDomainAvailabilityWatchNotFound) || errors.Is(err, happydns.ErrDomainLogNotFound) || errors.Is(err, happydns.ErrProviderNotFound) || errors.Is(err, happydns.ErrSessionNotFound) || errors.Is(err, happydns.ErrUserNotFound) || errors.Is(err, happydns.ErrUserAlreadyExist) || errors.Is(err, happydns.ErrZoneNotFound) {
c.AbortWithStatusJSON(http.StatusNotFound, happydns.ErrorResponse{
Message: err.Error(),
})

View file

@ -0,0 +1,38 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package route
import (
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/controller"
"git.happydns.org/happyDomain/model"
)
func DeclareDomainAvailabilityWatchRoutes(router *gin.RouterGroup, watchUC happydns.DomainAvailabilityWatchUsecase) {
wc := controller.NewDomainAvailabilityWatchController(watchUC)
router.GET("/availability", wc.ListDomainAvailabilityWatches)
router.POST("/availability", wc.AddDomainAvailabilityWatch)
router.GET("/availability/:watchId", wc.GetDomainAvailabilityWatch)
router.DELETE("/availability/:watchId", wc.DeleteDomainAvailabilityWatch)
}

View file

@ -40,28 +40,29 @@ import (
// Dependencies holds all use cases required to register the public API routes.
// It is a plain struct - no methods, no interface - constructed once in app.go.
type Dependencies struct {
Backup happydns.BackupUsecase
Authentication happydns.AuthenticationUsecase
AuthUser happydns.AuthUserUsecase
CaptchaVerifier happydns.CaptchaVerifier
Domain happydns.DomainUsecase
DomainInfo happydns.DomainInfoUsecase
DomainLog happydns.DomainLogUsecase
EmailAutoconfig happydns.EmailAutoconfigUsecase
FailureTracker happydns.FailureTracker
Provider happydns.ProviderUsecase
ProviderSettings happydns.ProviderSettingsUsecase
ProviderSpecs happydns.ProviderSpecsUsecase
RemoteZoneImporter happydns.RemoteZoneImporterUsecase
Resolver happydns.ResolverUsecase
Service happydns.ServiceUsecase
ServiceSpecs happydns.ServiceSpecsUsecase
Session happydns.SessionUsecase
User happydns.UserUsecase
Zone happydns.ZoneUsecase
ZoneCorrectionApplier happydns.ZoneCorrectionApplierUsecase
ZoneImporter happydns.ZoneImporterUsecase
ZoneService happydns.ZoneServiceUsecase
Backup happydns.BackupUsecase
Authentication happydns.AuthenticationUsecase
AuthUser happydns.AuthUserUsecase
CaptchaVerifier happydns.CaptchaVerifier
Domain happydns.DomainUsecase
DomainAvailabilityWatch happydns.DomainAvailabilityWatchUsecase
DomainInfo happydns.DomainInfoUsecase
DomainLog happydns.DomainLogUsecase
EmailAutoconfig happydns.EmailAutoconfigUsecase
FailureTracker happydns.FailureTracker
Provider happydns.ProviderUsecase
ProviderSettings happydns.ProviderSettingsUsecase
ProviderSpecs happydns.ProviderSpecsUsecase
RemoteZoneImporter happydns.RemoteZoneImporterUsecase
Resolver happydns.ResolverUsecase
Service happydns.ServiceUsecase
ServiceSpecs happydns.ServiceSpecsUsecase
Session happydns.SessionUsecase
User happydns.UserUsecase
Zone happydns.ZoneUsecase
ZoneCorrectionApplier happydns.ZoneCorrectionApplierUsecase
ZoneImporter happydns.ZoneImporterUsecase
ZoneService happydns.ZoneServiceUsecase
CheckerEngine happydns.CheckerEngine
CheckerOptionsUC *checkerUC.CheckerOptionsUsecase
@ -192,6 +193,9 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, dep Dependenc
dep.DomainInfo,
nc,
)
if dep.DomainAvailabilityWatch != nil {
DeclareDomainAvailabilityWatchRoutes(apiAuthRoutes, dep.DomainAvailabilityWatch)
}
DeclareProviderRoutes(apiAuthRoutes, dep.Provider)
DeclareProviderSettingsRoutes(apiAuthRoutes, dep.ProviderSettings)
DeclareRecordRoutes(apiAuthRoutes)

View file

@ -47,27 +47,28 @@ import (
)
type Usecases struct {
backup happydns.BackupUsecase
authentication happydns.AuthenticationUsecase
authUser happydns.AuthUserUsecase
authUserAdmin happydns.AdminAuthUserUsecase
domain happydns.DomainUsecase
domainAdmin happydns.AdminDomainUsecase
domainInfo happydns.DomainInfoUsecase
domainLog happydns.DomainLogUsecase
emailAutoconfig happydns.EmailAutoconfigUsecase
provider happydns.ProviderUsecase
providerAdmin happydns.ProviderUsecase
providerSpecs happydns.ProviderSpecsUsecase
providerSettings happydns.ProviderSettingsUsecase
resolver happydns.ResolverUsecase
session happydns.SessionUsecase
service happydns.ServiceUsecase
serviceSpecs happydns.ServiceSpecsUsecase
user happydns.UserUsecase
userAdmin happydns.AdminUserUsecase
zone happydns.ZoneUsecase
zoneService happydns.ZoneServiceUsecase
backup happydns.BackupUsecase
authentication happydns.AuthenticationUsecase
authUser happydns.AuthUserUsecase
authUserAdmin happydns.AdminAuthUserUsecase
domain happydns.DomainUsecase
domainAdmin happydns.AdminDomainUsecase
domainAvailabilityWatch happydns.DomainAvailabilityWatchUsecase
domainInfo happydns.DomainInfoUsecase
domainLog happydns.DomainLogUsecase
emailAutoconfig happydns.EmailAutoconfigUsecase
provider happydns.ProviderUsecase
providerAdmin happydns.ProviderUsecase
providerSpecs happydns.ProviderSpecsUsecase
providerSettings happydns.ProviderSettingsUsecase
resolver happydns.ResolverUsecase
session happydns.SessionUsecase
service happydns.ServiceUsecase
serviceSpecs happydns.ServiceSpecsUsecase
user happydns.UserUsecase
userAdmin happydns.AdminUserUsecase
zone happydns.ZoneUsecase
zoneService happydns.ZoneServiceUsecase
orchestrator *orchestrator.Orchestrator

View file

@ -70,6 +70,11 @@ func (s *instrumentedStorage) ClearDiscoveryObservationRefs() (err error) {
return s.inner.ClearDiscoveryObservationRefs()
}
func (s *instrumentedStorage) ClearDomainAvailabilityWatches() (err error) {
defer observe("delete", "domain_availability_watch")(&err)
return s.inner.ClearDomainAvailabilityWatches()
}
func (s *instrumentedStorage) ClearDomains() (err error) {
defer observe("delete", "domain")(&err)
return s.inner.ClearDomains()
@ -152,6 +157,11 @@ func (s *instrumentedStorage) CreateDomain(domain *happydns.Domain) (err error)
return s.inner.CreateDomain(domain)
}
func (s *instrumentedStorage) CreateDomainAvailabilityWatch(watch *happydns.DomainAvailabilityWatch) (err error) {
defer observe("create", "domain_availability_watch")(&err)
return s.inner.CreateDomainAvailabilityWatch(watch)
}
func (s *instrumentedStorage) CreateDomainLog(domain *happydns.Domain, log *happydns.DomainLog) (err error) {
defer observe("create", "domain_log")(&err)
return s.inner.CreateDomainLog(domain, log)
@ -232,6 +242,11 @@ func (s *instrumentedStorage) DeleteDomain(domainid happydns.Identifier) (err er
return s.inner.DeleteDomain(domainid)
}
func (s *instrumentedStorage) DeleteDomainAvailabilityWatch(id happydns.Identifier) (err error) {
defer observe("delete", "domain_availability_watch")(&err)
return s.inner.DeleteDomainAvailabilityWatch(id)
}
func (s *instrumentedStorage) DeleteDomainLog(domain *happydns.Domain, log *happydns.DomainLog) (err error) {
defer observe("delete", "domain_log")(&err)
return s.inner.DeleteDomainLog(domain, log)
@ -337,6 +352,11 @@ func (s *instrumentedStorage) GetDomain(domainid happydns.Identifier) (ret *happ
return s.inner.GetDomain(domainid)
}
func (s *instrumentedStorage) GetDomainAvailabilityWatch(id happydns.Identifier) (ret *happydns.DomainAvailabilityWatch, err error) {
defer observe("get", "domain_availability_watch")(&err)
return s.inner.GetDomainAvailabilityWatch(id)
}
func (s *instrumentedStorage) GetDomainByDN(user *happydns.User, fqdn string) (ret []*happydns.Domain, err error) {
defer observe("get", "domain")(&err)
return s.inner.GetDomainByDN(user, fqdn)
@ -447,6 +467,11 @@ func (s *instrumentedStorage) ListAllDiscoveryObservationRefs() (ret happydns.It
return s.inner.ListAllDiscoveryObservationRefs()
}
func (s *instrumentedStorage) ListAllDomainAvailabilityWatches() (ret happydns.Iterator[happydns.DomainAvailabilityWatch], err error) {
defer observe("list", "domain_availability_watch")(&err)
return s.inner.ListAllDomainAvailabilityWatches()
}
func (s *instrumentedStorage) ListAllDomainLogs() (ret happydns.Iterator[happydns.DomainLogWithDomainId], err error) {
defer observe("list", "domain_log")(&err)
return s.inner.ListAllDomainLogs()
@ -537,6 +562,11 @@ func (s *instrumentedStorage) ListDiscoveryObservationRefs(producerID string, ta
return s.inner.ListDiscoveryObservationRefs(producerID, target, ref)
}
func (s *instrumentedStorage) ListDomainAvailabilityWatches(user *happydns.User) (ret []*happydns.DomainAvailabilityWatch, err error) {
defer observe("list", "domain_availability_watch")(&err)
return s.inner.ListDomainAvailabilityWatches(user)
}
func (s *instrumentedStorage) ListDomainLogs(domain *happydns.Domain) (ret []*happydns.DomainLog, err error) {
defer observe("list", "domain_log")(&err)
return s.inner.ListDomainLogs(domain)
@ -701,6 +731,11 @@ func (s *instrumentedStorage) UpdateDomain(domain *happydns.Domain) (err error)
return s.inner.UpdateDomain(domain)
}
func (s *instrumentedStorage) UpdateDomainAvailabilityWatch(watch *happydns.DomainAvailabilityWatch) (err error) {
defer observe("update", "domain_availability_watch")(&err)
return s.inner.UpdateDomainAvailabilityWatch(watch)
}
func (s *instrumentedStorage) UpdateDomainLog(domain *happydns.Domain, log *happydns.DomainLog) (err error) {
defer observe("update", "domain_log")(&err)
return s.inner.UpdateDomainLog(domain, log)

View file

@ -57,28 +57,29 @@ func (app *App) setupRouter() {
app.cfg,
baserouter,
api.Dependencies{
Backup: app.usecases.backup,
Authentication: app.usecases.authentication,
AuthUser: app.usecases.authUser,
CaptchaVerifier: app.captchaVerifier,
Domain: app.usecases.domain,
DomainInfo: app.usecases.domainInfo,
DomainLog: app.usecases.domainLog,
EmailAutoconfig: app.usecases.emailAutoconfig,
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,
Backup: app.usecases.backup,
Authentication: app.usecases.authentication,
AuthUser: app.usecases.authUser,
CaptchaVerifier: app.captchaVerifier,
Domain: app.usecases.domain,
DomainAvailabilityWatch: app.usecases.domainAvailabilityWatch,
DomainInfo: app.usecases.domainInfo,
DomainLog: app.usecases.domainLog,
EmailAutoconfig: app.usecases.emailAutoconfig,
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,
CheckerEngine: app.usecases.checkerEngine,
CheckerOptionsUC: app.usecases.checkerOptionsUC,

View file

@ -28,6 +28,7 @@ import (
backupUC "git.happydns.org/happyDomain/internal/usecase/backup"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
domainUC "git.happydns.org/happyDomain/internal/usecase/domain"
domainAvailabilityUC "git.happydns.org/happyDomain/internal/usecase/domain_availability"
domainlogUC "git.happydns.org/happyDomain/internal/usecase/domain_log"
emailAutoconfigUC "git.happydns.org/happyDomain/internal/usecase/emailautoconfig"
notifUC "git.happydns.org/happyDomain/internal/usecase/notification"
@ -89,6 +90,8 @@ func (app *App) initUsecases() {
)
app.usecases.domain = domainService
app.usecases.domainAdmin = domainService
availabilityWatchService := domainAvailabilityUC.NewService(app.store)
app.usecases.domainAvailabilityWatch = availabilityWatchService
app.usecases.zoneService = zoneServiceUC.NewZoneServiceUsecases(
domainService,
zoneService.CreateZoneUC,
@ -125,6 +128,7 @@ func (app *App) initUsecases() {
// Checker system.
app.usecases.checkerOptionsUC = checkerUC.NewCheckerOptionsUsecase(app.store, app.store).
WithDiscoveryEntryStore(app.store).
WithWatchStore(app.store).
WithAdminOptions(app.cfg.CheckerAdminOptions)
app.usecases.checkerPlanUC = checkerUC.NewCheckPlanUsecase(app.store)
app.usecases.checkerStatusUC = checkerUC.NewCheckStatusUsecase(app.store, app.store, app.store, app.store, app.usecases.checkerOptionsUC)
@ -144,7 +148,7 @@ func (app *App) initUsecases() {
app.usecases.checkerScheduler = checkerUC.NewScheduler(
app.usecases.checkerEngine,
app.cfg.CheckerMaxConcurrency,
app.store, app.store, app.store, app.store,
app.store, app.store, app.store, app.store, app.store,
app.usecases.checkerUserGater.AllowWithInterval,
app.usecases.checkerUserGater.IncrementUsage,
)
@ -169,6 +173,7 @@ func (app *App) initUsecases() {
// Wire scheduler notifications for incremental queue updates.
domainService.SetSchedulerNotifier(app.usecases.checkerScheduler)
app.usecases.orchestrator.SetSchedulerNotifier(app.usecases.checkerScheduler)
availabilityWatchService.SetSchedulerNotifier(app.usecases.checkerScheduler)
// Notification system: dispatcher fans out checker results to user
// channels (email/webhook/UnifiedPush) based on per-target preferences.
@ -193,7 +198,7 @@ func (app *App) initUsecases() {
tester,
ack,
stateLocker,
)
).WithWatchStore(app.store)
if cb, ok := app.usecases.checkerEngine.(checkerUC.ExecutionCallbackSetter); ok {
cb.SetExecutionCallback(app.usecases.notificationDispatcher.OnExecutionComplete)
}

View file

@ -25,6 +25,7 @@ import (
"git.happydns.org/happyDomain/internal/usecase/authuser"
"git.happydns.org/happyDomain/internal/usecase/checker"
"git.happydns.org/happyDomain/internal/usecase/domain"
"git.happydns.org/happyDomain/internal/usecase/domain_availability"
"git.happydns.org/happyDomain/internal/usecase/domain_log"
"git.happydns.org/happyDomain/internal/usecase/insight"
"git.happydns.org/happyDomain/internal/usecase/notification"
@ -52,6 +53,7 @@ type Storage interface {
checker.ObservationSnapshotStorage
checker.SchedulerStateStorage
domain.DomainStorage
domain_availability.DomainAvailabilityWatchStorage
domainlog.DomainLogStorage
insight.InsightStorage
notification.NotificationChannelStorage

View file

@ -0,0 +1,149 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package database
import (
"errors"
"fmt"
"log"
"git.happydns.org/happyDomain/model"
)
// Secondary index for the availability-watch entity.
//
// availwatch.owner|{ownerId}|{watchId} -> "" reverse lookup by owner
const (
availWatchPrimaryPrefix = "availwatch-"
availWatchOwnerIndexPrefix = "availwatch.owner|"
)
func availWatchOwnerIndexKey(ownerId, watchId happydns.Identifier) string {
return fmt.Sprintf("%s%s|%s", availWatchOwnerIndexPrefix, ownerId.String(), watchId.String())
}
func (s *KVStorage) ListAllDomainAvailabilityWatches() (happydns.Iterator[happydns.DomainAvailabilityWatch], error) {
iter := s.db.Search(availWatchPrimaryPrefix)
return NewKVIterator[happydns.DomainAvailabilityWatch](s.db, iter), nil
}
func (s *KVStorage) ListDomainAvailabilityWatches(u *happydns.User) (watches []*happydns.DomainAvailabilityWatch, err error) {
prefix := fmt.Sprintf("%s%s|", availWatchOwnerIndexPrefix, u.Id.String())
iter := s.db.Search(prefix)
defer iter.Release()
for iter.Next() {
id, kerr := lastKeySegment(iter.Key())
if kerr != nil {
continue
}
w, gerr := s.GetDomainAvailabilityWatch(id)
if gerr != nil {
log.Printf("ListDomainAvailabilityWatches: stale owner index %q -> missing watch: %v", iter.Key(), gerr)
continue
}
watches = append(watches, w)
}
err = iter.Err()
return
}
func (s *KVStorage) getDomainAvailabilityWatch(key string) (*happydns.DomainAvailabilityWatch, error) {
watch := &happydns.DomainAvailabilityWatch{}
err := s.db.Get(key, watch)
if errors.Is(err, happydns.ErrNotFound) {
return nil, happydns.ErrDomainAvailabilityWatchNotFound
}
return watch, err
}
func (s *KVStorage) GetDomainAvailabilityWatch(id happydns.Identifier) (*happydns.DomainAvailabilityWatch, error) {
return s.getDomainAvailabilityWatch(fmt.Sprintf("%s%s", availWatchPrimaryPrefix, id.String()))
}
func (s *KVStorage) CreateDomainAvailabilityWatch(w *happydns.DomainAvailabilityWatch) error {
key, id, err := s.db.FindIdentifierKey(availWatchPrimaryPrefix)
if err != nil {
return err
}
w.Id = id
if err := s.db.Put(key, w); err != nil {
return err
}
return s.db.Put(availWatchOwnerIndexKey(w.Owner, w.Id), "")
}
func (s *KVStorage) UpdateDomainAvailabilityWatch(w *happydns.DomainAvailabilityWatch) error {
primaryKey := fmt.Sprintf("%s%s", availWatchPrimaryPrefix, w.Id.String())
// Load the previous record to detect owner changes. UpdateDomainAvailabilityWatch
// is used by the backup restore path where the primary may not exist yet, so
// a missing old record is not an error.
old, err := s.GetDomainAvailabilityWatch(w.Id)
if err != nil && !errors.Is(err, happydns.ErrDomainAvailabilityWatchNotFound) {
return err
}
if err := s.db.Put(primaryKey, w); err != nil {
return err
}
if old != nil && !old.Owner.Equals(w.Owner) {
if delErr := s.db.Delete(availWatchOwnerIndexKey(old.Owner, old.Id)); delErr != nil {
log.Printf("UpdateDomainAvailabilityWatch: failed to delete stale owner index for owner %s: %v", old.Owner.String(), delErr)
}
}
return s.db.Put(availWatchOwnerIndexKey(w.Owner, w.Id), "")
}
func (s *KVStorage) DeleteDomainAvailabilityWatch(id happydns.Identifier) error {
if w, err := s.GetDomainAvailabilityWatch(id); err == nil {
if delErr := s.db.Delete(availWatchOwnerIndexKey(w.Owner, w.Id)); delErr != nil {
log.Printf("DeleteDomainAvailabilityWatch: failed to delete owner index for owner %s: %v", w.Owner.String(), delErr)
}
}
return s.db.Delete(fmt.Sprintf("%s%s", availWatchPrimaryPrefix, id.String()))
}
func (s *KVStorage) ClearDomainAvailabilityWatches() error {
if err := s.clearByPrefix(availWatchOwnerIndexPrefix); err != nil {
return err
}
iter, err := s.ListAllDomainAvailabilityWatches()
if err != nil {
return err
}
defer iter.Close()
for iter.Next() {
if err = s.db.Delete(iter.Key()); err != nil {
return err
}
}
return iter.Err()
}

View file

@ -85,6 +85,14 @@ func (u *Usecase) backupOneUser(user *happydns.User, ret *happydns.Backup) {
} else {
ret.Sessions = append(ret.Sessions, ss...)
}
// Domain availability watches
ws, err := u.store.ListDomainAvailabilityWatches(user)
if err != nil {
ret.Errors = append(ret.Errors, fmt.Sprintf("unable to retrieve DomainAvailabilityWatches: %s", err.Error()))
} else {
ret.DomainAvailabilityWatches = append(ret.DomainAvailabilityWatches, ws...)
}
}
func (u *Usecase) Backup() happydns.Backup {
@ -319,6 +327,11 @@ func (u *Usecase) Restore(backup *happydns.Backup) error {
}
}
// Domain availability watches
for _, watch := range backup.DomainAvailabilityWatches {
errs = errors.Join(errs, u.store.UpdateDomainAvailabilityWatch(watch))
}
// Zones
for _, zmsg := range backup.Zones {
zone, err := zoneUC.ParseZone(zmsg)

View file

@ -150,6 +150,7 @@ type CheckerOptionsUsecase struct {
store CheckerOptionsStorage
autoFillStore CheckAutoFillStorage
discoveryStore DiscoveryEntryStorage
watchStore WatchGetter
adminOptions map[string]happydns.CheckerOptions
}
@ -167,6 +168,14 @@ func (u *CheckerOptionsUsecase) WithDiscoveryEntryStore(store DiscoveryEntryStor
return u
}
// WithWatchStore enables resolving a CheckTarget whose DomainId refers to a
// domain availability watch (rather than a real Domain) during auto-fill, so
// the availability checker receives the watched name. Passing nil is a no-op.
func (u *CheckerOptionsUsecase) WithWatchStore(store WatchGetter) *CheckerOptionsUsecase {
u.watchStore = store
return u
}
// WithAdminOptions installs per-checker admin-scope option overrides sourced
// from CLI flags / env vars. They are applied with the highest priority in
// GetCheckerOptions (so the admin panel reflects effective values) and in
@ -655,6 +664,15 @@ func (u *CheckerOptionsUsecase) buildAutoFillContext(
domain, err := u.autoFillStore.GetDomain(*domainId)
if err != nil {
// The DomainId may refer to an availability watch rather than a real
// Domain. Fall back to the watch store so the availability checker
// receives the watched name.
if u.watchStore != nil {
if watch, werr := u.watchStore.GetDomainAvailabilityWatch(*domainId); werr == nil {
ctx[happydns.AutoFillDomainName] = watch.DomainName
return ctx, nil
}
}
return ctx, fmt.Errorf("loading domain for auto-fill: %w", err)
}

View file

@ -39,6 +39,12 @@ const (
minSpacing = 2 * time.Second
maxCatchUpWindow = 10 * time.Minute
defaultInterval = 24 * time.Hour
// availabilityCheckerID is the id of the dedicated checker run against
// domain availability watches. It has all CheckerAvailability flags false
// so it is never auto-scheduled on managed domains; the scheduler enqueues
// it only from the watch enumeration.
availabilityCheckerID = "domain_availability"
)
// SchedulerJob represents a single scheduled checker execution.
@ -100,6 +106,7 @@ type Scheduler struct {
engine happydns.CheckerEngine
planStore CheckPlanStorage
domainStore DomainLister
watchStore WatchLister
zoneStore ZoneGetter
stateStore SchedulerStateStorage
cancel context.CancelFunc
@ -136,6 +143,7 @@ func NewScheduler(
maxConcurrency int,
planStore CheckPlanStorage,
domainStore DomainLister,
watchStore WatchLister,
zoneStore ZoneGetter,
stateStore SchedulerStateStorage,
gate func(target happydns.CheckTarget, interval time.Duration) bool,
@ -148,6 +156,7 @@ func NewScheduler(
engine: engine,
planStore: planStore,
domainStore: domainStore,
watchStore: watchStore,
zoneStore: zoneStore,
stateStore: stateStore,
jobKeys: make(map[string]bool),
@ -505,6 +514,15 @@ func (s *Scheduler) buildQueue() {
}
}
}
// Availability watches: schedule the dedicated availability checker for
// each watched name. These are not real domains, so domain-scoped checkers
// do not apply; only domain_availability runs, bypassing IsAutoScheduled.
if availDef, ok := checkers[availabilityCheckerID]; ok {
for _, watch := range s.loadAllWatches() {
s.enqueueWatchJob(availDef, watch, lastRun)
}
}
}
// NotifyDomainChange incrementally adds scheduler jobs for a domain
@ -636,6 +654,35 @@ func (s *Scheduler) NotifyDomainRemoved(domainID happydns.Identifier) {
}
}
// NotifyWatchChange incrementally adds the availability checker job for a
// newly created watch without rebuilding the entire queue.
func (s *Scheduler) NotifyWatchChange(watch *happydns.DomainAvailabilityWatch) {
availDef, ok := checkerPkg.GetCheckers()[availabilityCheckerID]
if !ok {
log.Printf("Scheduler: NotifyWatchChange: checker %q not registered", availabilityCheckerID)
return
}
s.mu.Lock()
added := s.enqueueWatchJob(availDef, watch, time.Time{})
s.mu.Unlock()
if added {
log.Printf("Scheduler: NotifyWatchChange(%s): added availability job", watch.DomainName)
select {
case s.wake <- struct{}{}:
default:
}
}
}
// NotifyWatchRemoved removes the availability checker job for the given watch.
// The watch id is carried in CheckTarget.DomainId, so removal matches on the
// same field as NotifyDomainRemoved.
func (s *Scheduler) NotifyWatchRemoved(watchID happydns.Identifier) {
s.NotifyDomainRemoved(watchID)
}
// serviceCheckerApplies reports whether a service-scoped checker should be
// auto-scheduled for the given service type. Auto-scheduling is restricted to
// checkers that explicitly declare the service type in LimitToServices; a
@ -747,6 +794,72 @@ func (s *Scheduler) loadAllDomains() []*happydns.Domain {
return domains
}
func (s *Scheduler) loadAllWatches() []*happydns.DomainAvailabilityWatch {
if s.watchStore == nil {
return nil
}
iter, err := s.watchStore.ListAllDomainAvailabilityWatches()
if err != nil {
log.Printf("Scheduler: failed to list availability watches: %v", err)
return nil
}
defer iter.Close()
var watches []*happydns.DomainAvailabilityWatch
for iter.Next() {
watches = append(watches, iter.Item())
}
return watches
}
// enqueueWatchJob enqueues the availability checker for a single watch. It
// mirrors enqueueJob but carries the watch id in CheckTarget.DomainId and
// honours the watch's optional custom interval (clamped to the checker's
// bounds). Must be called with s.mu held. Returns true if a job was added.
func (s *Scheduler) enqueueWatchJob(def *happydns.CheckerDefinition, watch *happydns.DomainAvailabilityWatch, lastActive time.Time) bool {
target := happydns.CheckTarget{UserId: watch.Owner.String(), DomainId: watch.Id.String()}
targetStr := target.String()
key := availabilityCheckerID + "|" + targetStr
if s.jobKeys[key] {
return false
}
interval := s.effectiveInterval(def, nil)
if watch.Interval != nil && *watch.Interval > 0 {
interval = *watch.Interval
if def.Interval != nil {
if interval < def.Interval.Min {
interval = def.Interval.Min
}
if interval > def.Interval.Max {
interval = def.Interval.Max
}
}
}
if interval <= 0 {
interval = defaultInterval
}
var nextRun time.Time
if lastActive.IsZero() {
now := time.Now()
nextRun = now.Add(computeJitter(availabilityCheckerID, targetStr, now, interval))
} else {
offset := computeOffset(availabilityCheckerID, targetStr, interval)
nextRun = computeNextRun(interval, offset, lastActive)
}
job := &SchedulerJob{
CheckerID: availabilityCheckerID,
Target: target,
Interval: interval,
NextRun: nextRun,
}
heap.Push(&s.queue, job)
s.jobKeys[key] = true
return true
}
func (s *Scheduler) loadDomainServices(domain *happydns.Domain) []*happydns.ServiceMessage {
if s.zoneStore == nil || len(domain.ZoneHistory) == 0 {
return nil

View file

@ -202,7 +202,7 @@ func newTestScheduler(engine happydns.CheckerEngine, domains []*happydns.Domain)
dl := &mockDomainLister{domains: domains}
zg := &mockZoneGetter{zones: make(map[string]*happydns.ZoneMessage)}
ss := &mockStateStore{}
sched := NewScheduler(engine, 2, ps, dl, zg, ss, nil, nil)
sched := NewScheduler(engine, 2, ps, dl, nil, zg, ss, nil, nil)
return sched, ps, ss
}
@ -343,7 +343,7 @@ func TestScheduler_Gate(t *testing.T) {
dl := &mockDomainLister{domains: []*happydns.Domain{domain}}
zg := &mockZoneGetter{zones: make(map[string]*happydns.ZoneMessage)}
ss := &mockStateStore{}
sched := NewScheduler(engine, 2, ps, dl, zg, ss, func(target happydns.CheckTarget, interval time.Duration) bool {
sched := NewScheduler(engine, 2, ps, dl, nil, zg, ss, func(target happydns.CheckTarget, interval time.Duration) bool {
gated.Add(1)
return false // block all jobs
}, nil)
@ -386,7 +386,7 @@ func TestScheduler_OnExecute_CalledOnSuccess(t *testing.T) {
var onExecCalls atomic.Int32
var lastTarget atomic.Value // happydns.CheckTarget
sched := NewScheduler(engine, 2, ps, dl, zg, ss, nil, func(target happydns.CheckTarget) {
sched := NewScheduler(engine, 2, ps, dl, nil, zg, ss, nil, func(target happydns.CheckTarget) {
onExecCalls.Add(1)
lastTarget.Store(target)
})
@ -432,7 +432,7 @@ func TestScheduler_OnExecute_NotCalledWhenCreateFails(t *testing.T) {
ss := &mockStateStore{}
var onExecCalls atomic.Int32
sched := NewScheduler(engine, 2, ps, dl, zg, ss, nil, func(target happydns.CheckTarget) {
sched := NewScheduler(engine, 2, ps, dl, nil, zg, ss, nil, func(target happydns.CheckTarget) {
onExecCalls.Add(1)
})
@ -473,7 +473,7 @@ func TestScheduler_OnExecute_NotCalledWhenGateDenies(t *testing.T) {
ss := &mockStateStore{}
var onExecCalls atomic.Int32
sched := NewScheduler(engine, 2, ps, dl, zg, ss,
sched := NewScheduler(engine, 2, ps, dl, nil, zg, ss,
func(target happydns.CheckTarget, interval time.Duration) bool { return false },
func(target happydns.CheckTarget) { onExecCalls.Add(1) },
)

View file

@ -43,6 +43,18 @@ type ZoneGetter interface {
GetZone(id happydns.Identifier) (*happydns.ZoneMessage, error)
}
// WatchLister is the minimal interface needed by the scheduler to enumerate
// domain availability watches.
type WatchLister interface {
ListAllDomainAvailabilityWatches() (happydns.Iterator[happydns.DomainAvailabilityWatch], error)
}
// WatchGetter resolves a single availability watch by id. Used as a fallback
// when a CheckTarget's DomainId refers to a watch rather than a real domain.
type WatchGetter interface {
GetDomainAvailabilityWatch(id happydns.Identifier) (*happydns.DomainAvailabilityWatch, error)
}
// CheckAutoFillStorage provides access to domain, zone and user data
// needed to resolve auto-fill field values at execution time.
type CheckAutoFillStorage interface {

View file

@ -0,0 +1,111 @@
// 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_availability
import (
"context"
"fmt"
"git.happydns.org/happyDomain/model"
)
type Service struct {
store DomainAvailabilityWatchStorage
schedulerNotifier happydns.SchedulerWatchNotifier
}
func NewService(store DomainAvailabilityWatchStorage) *Service {
return &Service{store: store}
}
// SetSchedulerNotifier sets the optional scheduler notifier for incremental
// queue updates on watch creation/deletion.
func (s *Service) SetSchedulerNotifier(notifier happydns.SchedulerWatchNotifier) {
s.schedulerNotifier = notifier
}
func (s *Service) CreateDomainAvailabilityWatch(ctx context.Context, user *happydns.User, input *happydns.DomainAvailabilityWatchCreationInput) (*happydns.DomainAvailabilityWatch, error) {
watch, err := happydns.NewDomainAvailabilityWatch(user, input.DomainName)
if err != nil {
return nil, happydns.ValidationError{Msg: err.Error()}
}
watch.Interval = input.Interval
if err := s.store.CreateDomainAvailabilityWatch(watch); err != nil {
return nil, happydns.InternalError{
Err: fmt.Errorf("unable to CreateDomainAvailabilityWatch: %w", err),
UserMessage: "Sorry, we are unable to create your availability watch now.",
}
}
if s.schedulerNotifier != nil {
s.schedulerNotifier.NotifyWatchChange(watch)
}
return watch, nil
}
func (s *Service) GetUserDomainAvailabilityWatch(user *happydns.User, id happydns.Identifier) (*happydns.DomainAvailabilityWatch, error) {
watch, err := s.store.GetDomainAvailabilityWatch(id)
if err != nil {
return nil, err
}
if !user.Id.Equals(watch.Owner) {
return nil, happydns.ErrDomainAvailabilityWatchNotFound
}
return watch, nil
}
func (s *Service) ListUserDomainAvailabilityWatches(user *happydns.User) ([]*happydns.DomainAvailabilityWatch, error) {
watches, err := s.store.ListDomainAvailabilityWatches(user)
if err != nil {
return nil, fmt.Errorf("unable to ListUserDomainAvailabilityWatches: %w", err)
}
if len(watches) == 0 {
return []*happydns.DomainAvailabilityWatch{}, nil
}
return watches, nil
}
func (s *Service) DeleteDomainAvailabilityWatch(user *happydns.User, id happydns.Identifier) error {
// Ensure the watch exists and is owned by the caller before deleting.
if _, err := s.GetUserDomainAvailabilityWatch(user, id); err != nil {
return err
}
if err := s.store.DeleteDomainAvailabilityWatch(id); err != nil {
return happydns.InternalError{
Err: fmt.Errorf("unable to DeleteDomainAvailabilityWatch: %w", err),
UserMessage: "Sorry, we are unable to delete your availability watch now.",
}
}
if s.schedulerNotifier != nil {
s.schedulerNotifier.NotifyWatchRemoved(id)
}
return nil
}

View file

@ -0,0 +1,50 @@
// 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_availability
import (
"git.happydns.org/happyDomain/model"
)
type DomainAvailabilityWatchStorage interface {
// ListAllDomainAvailabilityWatches retrieves every registered watch.
ListAllDomainAvailabilityWatches() (happydns.Iterator[happydns.DomainAvailabilityWatch], error)
// ListDomainAvailabilityWatches retrieves all watches owned by the User.
ListDomainAvailabilityWatches(user *happydns.User) ([]*happydns.DomainAvailabilityWatch, error)
// GetDomainAvailabilityWatch retrieves the watch with the given id.
GetDomainAvailabilityWatch(id happydns.Identifier) (*happydns.DomainAvailabilityWatch, error)
// CreateDomainAvailabilityWatch persists a new watch.
CreateDomainAvailabilityWatch(watch *happydns.DomainAvailabilityWatch) error
// UpdateDomainAvailabilityWatch persists an existing watch, preserving its
// id. Used by the backup restore path.
UpdateDomainAvailabilityWatch(watch *happydns.DomainAvailabilityWatch) error
// DeleteDomainAvailabilityWatch removes the watch with the given id.
DeleteDomainAvailabilityWatch(id happydns.Identifier) error
// ClearDomainAvailabilityWatches deletes all watches.
ClearDomainAvailabilityWatches() error
}

View file

@ -37,6 +37,7 @@ type Dispatcher struct {
userStore UserGetter
domainStore DomainGetter
zoneStore ZoneGetter
watchStore WatchGetter
resolver *Resolver
pool *Pool
@ -74,6 +75,14 @@ func NewDispatcher(
}
}
// WithWatchStore enables resolving a CheckTarget whose DomainId refers to a
// domain availability watch (rather than a real Domain), so notifications carry
// the watched name. Passing nil is a no-op.
func (d *Dispatcher) WithWatchStore(store WatchGetter) *Dispatcher {
d.watchStore = store
return d
}
func (d *Dispatcher) Start() { d.pool.Start() }
func (d *Dispatcher) Stop() { d.pool.Stop() }
@ -193,6 +202,15 @@ func (d *Dispatcher) buildPayload(user *happydns.User, exec *happydns.Execution,
}
}
}
if domainName == "" && d.watchStore != nil {
// The DomainId may refer to an availability watch rather than a real
// Domain. Fall back to the watch store for the watched name.
if did := happydns.TargetIdentifier(exec.Target.DomainId); did != nil {
if watch, err := d.watchStore.GetDomainAvailabilityWatch(*did); err == nil {
domainName = watch.DomainName
}
}
}
if domainName == "" {
domainName = "(unknown domain)"
}

View file

@ -67,3 +67,10 @@ type DomainGetter interface {
type ZoneGetter interface {
GetZone(id happydns.Identifier) (*happydns.ZoneMessage, error)
}
// WatchGetter resolves a domain availability watch by id. Used as a fallback
// when a notification's CheckTarget.DomainId refers to a watch rather than a
// real Domain, so the payload carries the watched name.
type WatchGetter interface {
GetDomainAvailabilityWatch(id happydns.Identifier) (*happydns.DomainAvailabilityWatch, error)
}

View file

@ -22,22 +22,23 @@
package happydns
type Backup struct {
Version int
Domains []*Domain
DomainsLogs map[string][]*DomainLog
Errors []string
Providers []*ProviderMessage
Sessions []*Session
Users []*User
UsersAuth UserAuths
Zones []*ZoneMessage
CheckerConfigurations []*CheckerOptionsPositional
CheckPlans []*CheckPlan
CheckEvaluations []*CheckEvaluation
Executions []*Execution
DiscoveryEntries []*StoredDiscoveryEntry
DiscoveryObservationRefs []*DiscoveryObservationRef
ObservationSnapshots []*ObservationSnapshot
Version int
Domains []*Domain
DomainAvailabilityWatches []*DomainAvailabilityWatch
DomainsLogs map[string][]*DomainLog
Errors []string
Providers []*ProviderMessage
Sessions []*Session
Users []*User
UsersAuth UserAuths
Zones []*ZoneMessage
CheckerConfigurations []*CheckerOptionsPositional
CheckPlans []*CheckPlan
CheckEvaluations []*CheckEvaluation
Executions []*Execution
DiscoveryEntries []*StoredDiscoveryEntry
DiscoveryObservationRefs []*DiscoveryObservationRef
ObservationSnapshots []*ObservationSnapshot
}
// BackupUsecase orchestrates the export and re-import of every persistent

View file

@ -0,0 +1,98 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package happydns
import (
"context"
"errors"
"strings"
"time"
"github.com/miekg/dns"
)
// DomainAvailabilityWatch tracks a domain name the User does NOT own, so they
// can be notified the moment it becomes available for registration. Unlike a
// Domain, a watch is not tied to any Provider and never manages a zone.
type DomainAvailabilityWatch struct {
// Id is the watch's identifier in the database.
Id Identifier `json:"id" swaggertype:"string" binding:"required" readonly:"true"`
// Owner is the identifier of the User watching the domain.
Owner Identifier `json:"id_owner" swaggertype:"string" binding:"required" readonly:"true"`
// DomainName is the FQDN being watched for availability.
DomainName string `json:"domain" binding:"required"`
// Interval optionally overrides how often the availability check runs.
// When nil, the checker's default interval is used.
Interval *time.Duration `json:"interval,omitempty" swaggertype:"integer"`
// CreatedAt records when the watch was registered.
CreatedAt time.Time `json:"created_at" readonly:"true"`
}
// DomainAvailabilityWatchCreationInput is used for swagger documentation as
// availability watch add.
type DomainAvailabilityWatchCreationInput struct {
// DomainName is the FQDN to watch for availability.
DomainName string `json:"domain" binding:"required"`
// Interval optionally overrides the default check interval.
Interval *time.Duration `json:"interval,omitempty" swaggertype:"integer"`
}
// NewDomainAvailabilityWatch validates the name and builds a watch owned by the
// given user.
func NewDomainAvailabilityWatch(user *User, name string) (*DomainAvailabilityWatch, error) {
name = dns.Fqdn(strings.TrimSpace(name))
if name == "." {
return nil, errors.New("empty domain name")
}
if _, ok := dns.IsDomainName(name); !ok {
return nil, errors.New("invalid domain name")
}
return &DomainAvailabilityWatch{
Owner: user.Id,
DomainName: name,
CreatedAt: time.Now(),
}, nil
}
// DomainAvailabilityWatchUsecase exposes owner-scoped operations on the
// availability watchlist.
type DomainAvailabilityWatchUsecase interface {
CreateDomainAvailabilityWatch(context.Context, *User, *DomainAvailabilityWatchCreationInput) (*DomainAvailabilityWatch, error)
DeleteDomainAvailabilityWatch(*User, Identifier) error
GetUserDomainAvailabilityWatch(*User, Identifier) (*DomainAvailabilityWatch, error)
ListUserDomainAvailabilityWatches(*User) ([]*DomainAvailabilityWatch, error)
}
// SchedulerWatchNotifier is an optional callback to notify the scheduler about
// availability-watch changes so it can incrementally update its job queue.
type SchedulerWatchNotifier interface {
NotifyWatchChange(watch *DomainAvailabilityWatch)
NotifyWatchRemoved(watchID Identifier)
}

View file

@ -27,24 +27,25 @@ import (
)
var (
ErrAuthUserNotFound = errors.New("auth user not found")
ErrCheckPlanNotFound = errors.New("check plan not found")
ErrCheckEvaluationNotFound = errors.New("check evaluation not found")
ErrCheckerNotFound = errors.New("checker not found")
ErrDomainDoesNotExist = errors.New("domain name doesn't exist")
ErrDomainNotFound = errors.New("domain not found")
ErrDomainLogNotFound = errors.New("domain log not found")
ErrExecutionNotFound = errors.New("execution not found")
ErrNotificationChannelNotFound = errors.New("notification channel not found")
ErrNotificationPreferenceNotFound = errors.New("notification preference not found")
ErrNotificationStateNotFound = errors.New("notification state not found")
ErrProviderNotFound = errors.New("provider not found")
ErrSessionNotFound = errors.New("session not found")
ErrSnapshotNotFound = errors.New("snapshot not found")
ErrUserNotFound = errors.New("user not found")
ErrUserAlreadyExist = errors.New("user already exists")
ErrZoneNotFound = errors.New("zone not found")
ErrNotFound = errors.New("not found")
ErrAuthUserNotFound = errors.New("auth user not found")
ErrCheckPlanNotFound = errors.New("check plan not found")
ErrCheckEvaluationNotFound = errors.New("check evaluation not found")
ErrCheckerNotFound = errors.New("checker not found")
ErrDomainAvailabilityWatchNotFound = errors.New("domain availability watch not found")
ErrDomainDoesNotExist = errors.New("domain name doesn't exist")
ErrDomainNotFound = errors.New("domain not found")
ErrDomainLogNotFound = errors.New("domain log not found")
ErrExecutionNotFound = errors.New("execution not found")
ErrNotificationChannelNotFound = errors.New("notification channel not found")
ErrNotificationPreferenceNotFound = errors.New("notification preference not found")
ErrNotificationStateNotFound = errors.New("notification state not found")
ErrProviderNotFound = errors.New("provider not found")
ErrSessionNotFound = errors.New("session not found")
ErrSnapshotNotFound = errors.New("snapshot not found")
ErrUserNotFound = errors.New("user not found")
ErrUserAlreadyExist = errors.New("user already exists")
ErrZoneNotFound = errors.New("zone not found")
ErrNotFound = errors.New("not found")
)
const TryAgainErr = "Sorry, we are currently unable to sent email validation link. Please try again later."

View file

@ -51,6 +51,7 @@ var entityMap = map[string]string{
"ObservationSnapshotStorage": "observation_snapshot",
"SchedulerStateStorage": "scheduler_state",
"DomainStorage": "domain",
"DomainAvailabilityWatchStorage": "domain_availability_watch",
"DomainLogStorage": "domain_log",
"InsightStorage": "insight",
"NotificationChannelStorage": "notification_channel",

View file

@ -0,0 +1,68 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2022-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <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/>.
import {
getAvailability,
getAvailabilityByWatchId,
postAvailability,
deleteAvailabilityByWatchId,
} from "$lib/api-base/sdk.gen";
import type {
HappydnsDomainAvailabilityWatch,
HappydnsDomainAvailabilityWatchCreationInput,
} from "$lib/api-base/types.gen";
import { unwrapSdkResponse, unwrapEmptyResponse } from "./errors";
export async function listAvailabilityWatches(): Promise<
Array<HappydnsDomainAvailabilityWatch>
> {
return unwrapSdkResponse(
await getAvailability(),
) as Array<HappydnsDomainAvailabilityWatch>;
}
export async function getAvailabilityWatch(
id: string,
): Promise<HappydnsDomainAvailabilityWatch> {
return unwrapSdkResponse(
await getAvailabilityByWatchId({
path: { watchId: id },
}),
) as HappydnsDomainAvailabilityWatch;
}
export async function addAvailabilityWatch(
body: HappydnsDomainAvailabilityWatchCreationInput,
): Promise<HappydnsDomainAvailabilityWatch> {
return unwrapSdkResponse(
await postAvailability({
body,
}),
) as HappydnsDomainAvailabilityWatch;
}
export async function deleteAvailabilityWatch(id: string): Promise<boolean> {
return unwrapEmptyResponse(
await deleteAvailabilityByWatchId({
path: { watchId: id },
}),
);
}

View file

@ -139,6 +139,13 @@
<Icon name="info-circle" class="me-2" />
{$t("menu.whois")}
</DropdownItem>
<DropdownItem
active={page.route && page.route.id == "/availability"}
href="/availability"
>
<Icon name="bell" class="me-2" />
{$t("menu.availability")}
</DropdownItem>
<DropdownItem
active={page.route &&
(page.route.id == "/checkers" ||

View file

@ -281,7 +281,8 @@
"provider-features": "Supported providers",
"signup": "Sign up",
"signin": "Sign in",
"quick-menu": "Quick Access"
"quick-menu": "Quick Access",
"availability": "Availability watchlist"
},
"onboarding": {
"steps": {
@ -1434,5 +1435,19 @@
"detail": "The mechanism {{mechanism}} could not be fully evaluated."
}
}
},
"availability": {
"title": "Availability watchlist",
"description": "Get notified as soon as a domain you do not own becomes available for registration.",
"kind": "watch",
"add": "Watch a domain",
"domain-placeholder": "example.com",
"empty": "You are not watching any domain yet.",
"status-available": "Available",
"status-registered": "Still registered",
"added-success": "Now watching {{domain}} for availability.",
"deleted-success": "Stopped watching {{domain}}.",
"delete-confirm": "Stop watching {{domain}}?",
"watched-since": "Watched since"
}
}

View file

@ -279,7 +279,8 @@
"signup": "S'inscrire",
"signin": "S'identifier",
"quick-menu": "Accès rapide",
"my-providers": "Les hébergeurs de mes domaines"
"my-providers": "Les hébergeurs de mes domaines",
"availability": "Surveillance de disponibilité"
},
"onboarding": {
"steps": {
@ -1074,5 +1075,19 @@
"detail": "Le mécanisme {{mechanism}} n'a pas pu être totalement évalué."
}
}
},
"availability": {
"title": "Liste de surveillance de disponibilité",
"description": "Soyez notifié dès qu'un domaine que vous ne possédez pas devient disponible à l'enregistrement.",
"kind": "surveillance",
"add": "Surveiller un domaine",
"domain-placeholder": "exemple.fr",
"empty": "Vous ne surveillez aucun domaine pour le moment.",
"status-available": "Disponible",
"status-registered": "Toujours enregistré",
"added-success": "Surveillance de la disponibilité de {{domain}} activée.",
"deleted-success": "Surveillance de {{domain}} arrêtée.",
"delete-confirm": "Arrêter de surveiller {{domain}} ?",
"watched-since": "Surveillé depuis"
}
}

View file

@ -0,0 +1,34 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2022-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <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/>.
import { writable, type Writable } from "svelte/store";
import { listAvailabilityWatches } from "$lib/api/availability";
import type { HappydnsDomainAvailabilityWatch } from "$lib/api-base/types.gen";
export const availabilityWatches: Writable<
Array<HappydnsDomainAvailabilityWatch> | undefined
> = writable(undefined);
export async function refreshAvailabilityWatches() {
const data = await listAvailabilityWatches();
availabilityWatches.set(data);
return data;
}

View file

@ -0,0 +1,160 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-2026 happyDomain
Authors: Pierre-Olivier Mercier, et al.
This program is offered under a commercial and under the AGPL license.
For commercial licensing, contact us at <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/>.
-->
<script lang="ts">
import { onMount } from "svelte";
import {
Button,
Container,
Icon,
Input,
InputGroup,
ListGroup,
ListGroupItem,
Spinner,
} from "@sveltestrap/sveltestrap";
import { addAvailabilityWatch, deleteAvailabilityWatch } from "$lib/api/availability";
import PageTitle from "$lib/components/PageTitle.svelte";
import { availabilityWatches, refreshAvailabilityWatches } from "$lib/stores/availability";
import { toasts } from "$lib/stores/toasts";
import { t } from "$lib/translations";
let newDomain = $state("");
let adding = $state(false);
onMount(() => {
refreshAvailabilityWatches();
});
async function onAdd() {
const domain = newDomain.trim();
if (!domain) return;
adding = true;
try {
await addAvailabilityWatch({ domain });
toasts.addToast({
title: $t("availability.add"),
message: $t("availability.added-success", { domain }),
type: "success",
timeout: 5000,
});
newDomain = "";
await refreshAvailabilityWatches();
} catch (err) {
toasts.addErrorToast({
title: $t("errors.error"),
message: err instanceof Error ? err.message : String(err),
});
} finally {
adding = false;
}
}
async function onDelete(id: string, domain: string) {
if (!confirm($t("availability.delete-confirm", { domain }))) return;
try {
await deleteAvailabilityWatch(id);
toasts.addToast({
title: $t("availability.title"),
message: $t("availability.deleted-success", { domain }),
type: "success",
timeout: 5000,
});
await refreshAvailabilityWatches();
} catch (err) {
toasts.addErrorToast({
title: $t("errors.error"),
message: err instanceof Error ? err.message : String(err),
});
}
}
</script>
<svelte:head>
<title>{$t("availability.title")} - happyDomain</title>
</svelte:head>
<Container class="flex-fill my-5">
<PageTitle title={$t("availability.title")} subtitle={$t("availability.description")} />
<form
class="mb-4 mt-3"
onsubmit={(e) => {
e.preventDefault();
onAdd();
}}
>
<InputGroup>
<Input
type="text"
placeholder={$t("availability.domain-placeholder")}
bind:value={newDomain}
disabled={adding}
/>
<Button type="submit" color="primary" disabled={adding || !newDomain.trim()}>
{#if adding}
<Spinner size="sm" />
{:else}
<Icon name="plus" />
{/if}
{$t("availability.add")}
</Button>
</InputGroup>
</form>
{#if $availabilityWatches === undefined}
<div class="text-center my-5">
<Spinner />
</div>
{:else if $availabilityWatches.length === 0}
<p class="text-muted text-center my-5">{$t("availability.empty")}</p>
{:else}
<ListGroup>
{#each $availabilityWatches as watch (watch.id)}
<ListGroupItem class="d-flex justify-content-between align-items-center">
<div>
<span class="font-monospace fw-bold">{watch.domain}</span>
{#if watch.created_at}
<small class="text-muted ms-2">
{$t("availability.watched-since")}
{new Date(watch.created_at).toLocaleDateString()}
</small>
{/if}
</div>
<Button
type="button"
color="danger"
outline
size="sm"
onclick={() => onDelete(watch.id, watch.domain)}
>
<Icon name="trash" />
</Button>
</ListGroupItem>
{/each}
</ListGroup>
{/if}
</Container>