diff --git a/checks/interface.go b/checks/interface.go new file mode 100644 index 00000000..fb008bd0 --- /dev/null +++ b/checks/interface.go @@ -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 . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Package 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 +} diff --git a/checks/zonemaster.go b/checks/zonemaster.go new file mode 100644 index 00000000..f58b6286 --- /dev/null +++ b/checks/zonemaster.go @@ -0,0 +1,307 @@ +package checks + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "git.happydns.org/happyDomain/model" +) + +func init() { + RegisterChecker("zonemaster", &ZonemasterCheck{}) +} + +type ZonemasterCheck struct{} + +func (p *ZonemasterCheck) ID() string { + return "zonemaster" +} + +func (p *ZonemasterCheck) Name() string { + return "Zonemaster" +} + +func (p *ZonemasterCheck) Availability() happydns.CheckerAvailability { + return happydns.CheckerAvailability{ + ApplyToDomain: true, + } +} + +func (p *ZonemasterCheck) Options() happydns.CheckerOptionsDocumentation { + return happydns.CheckerOptionsDocumentation{ + RunOpts: []happydns.CheckerOptionDocumentation{ + { + Id: "domainName", + Type: "string", + Label: "Domain name to check", + AutoFill: happydns.AutoFillDomainName, + Required: true, + }, + { + Id: "profile", + Type: "string", + Label: "Profile", + Placeholder: "default", + Default: "default", + }, + }, + UserOpts: []happydns.CheckerOptionDocumentation{ + { + Id: "language", + Type: "select", + Label: "Result language", + Default: "en", + Choices: []string{ + "en", // English + "fr", // French + "de", // German + "es", // Spanish + "sv", // Swedish + "da", // Danish + "fi", // Finnish + "nb", // Norwegian Bokmål + "nl", // Dutch + "pt", // Portuguese + }, + }, + }, + AdminOpts: []happydns.CheckerOptionDocumentation{ + { + Id: "zonemasterAPIURL", + Type: "string", + Label: "Zonemaster API URL", + Placeholder: "https://zonemaster.net/api", + Default: "https://zonemaster.net/api", + }, + }, + } +} + +// JSON-RPC request/response structures +type jsonRPCRequest struct { + Jsonrpc string `json:"jsonrpc"` + Method string `json:"method"` + Params any `json:"params"` + ID int `json:"id"` +} + +type jsonRPCResponse struct { + Jsonrpc string `json:"jsonrpc"` + Result json.RawMessage `json:"result,omitempty"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error,omitempty"` + ID int `json:"id"` +} + +// Zonemaster API structures +type startCheckParams struct { + Domain string `json:"domain"` + Profile string `json:"profile,omitempty"` + IPv4 bool `json:"ipv4,omitempty"` + IPv6 bool `json:"ipv6,omitempty"` +} + +type checkProgressParams struct { + CheckID string `json:"check_id"` +} + +type getResultsParams struct { + ID string `json:"id"` + Language string `json:"language"` +} + +type checkResult struct { + Module string `json:"module"` + Message string `json:"message"` + Level string `json:"level"` + Checkcase string `json:"checkcase,omitempty"` +} + +type zonemasterResults struct { + CreatedAt string `json:"created_at"` + HashID string `json:"hash_id"` + Params map[string]any `json:"params"` + Results []checkResult `json:"results"` + CheckcaseDescriptions map[string]string `json:"checkcase_descriptions,omitempty"` +} + +func (p *ZonemasterCheck) callJSONRPC(apiURL, method string, params any) (json.RawMessage, error) { + reqBody := jsonRPCRequest{ + Jsonrpc: "2.0", + Method: method, + Params: params, + ID: 1, + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + resp, err := http.Post(apiURL, "application/json", bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to call API: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body)) + } + + var rpcResp jsonRPCResponse + if err := json.NewDecoder(resp.Body).Decode(&rpcResp); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + if rpcResp.Error != nil { + return nil, fmt.Errorf("API error %d: %s", rpcResp.Error.Code, rpcResp.Error.Message) + } + + return rpcResp.Result, nil +} + +func (p *ZonemasterCheck) RunCheck(options happydns.CheckerOptions, meta map[string]string) (*happydns.CheckResult, error) { + // Extract options + domainName, ok := options["domainName"].(string) + if !ok || domainName == "" { + return nil, fmt.Errorf("domainName is required") + } + domainName = strings.TrimSuffix(domainName, ".") + + apiURL, ok := options["zonemasterAPIURL"].(string) + if !ok || apiURL == "" { + return nil, fmt.Errorf("zonemasterAPIURL is required") + } + apiURL = strings.TrimSuffix(apiURL, "/") + + language := "en" + if lang, ok := options["language"].(string); ok && lang != "" { + language = lang + } + + profile := "default" + if prof, ok := options["profile"].(string); ok && prof != "" { + profile = prof + } + + // Step 1: Start the check + startParams := startCheckParams{ + Domain: domainName, + Profile: profile, + IPv4: true, + IPv6: true, + } + + result, err := p.callJSONRPC(apiURL, "start_domain_check", startParams) + if err != nil { + return nil, fmt.Errorf("failed to start check: %w", err) + } + + var checkID string + if err := json.Unmarshal(result, &checkID); err != nil { + return nil, fmt.Errorf("failed to parse check ID: %w", err) + } + + if checkID == "" { + return nil, fmt.Errorf("received empty check ID") + } + + // Step 2: Poll for check completion + progressParams := checkProgressParams{CheckID: checkID} + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + timeout := time.After(10 * time.Minute) + for { + select { + case <-timeout: + return nil, fmt.Errorf("check timeout after 10 minutes (check ID: %s)", checkID) + + case <-ticker.C: + result, err := p.callJSONRPC(apiURL, "check_progress", progressParams) + if err != nil { + return nil, fmt.Errorf("failed to check progress: %w", err) + } + + var progress float64 + if err := json.Unmarshal(result, &progress); err != nil { + return nil, fmt.Errorf("failed to parse progress: %w", err) + } + + if progress >= 100 { + goto checkComplete + } + } + } + +checkComplete: + // Step 3: Get check results + resultsParams := getResultsParams{ + ID: checkID, + Language: language, + } + + result, err = p.callJSONRPC(apiURL, "get_check_results", resultsParams) + if err != nil { + return nil, fmt.Errorf("failed to get results: %w", err) + } + + var results zonemasterResults + if err := json.Unmarshal(result, &results); err != nil { + return nil, fmt.Errorf("failed to parse results: %w", err) + } + + // Analyze results to determine overall status + var ( + errorCount int + warningCount int + infoCount int + criticalMsgs []string + ) + + for _, r := range results.Results { + switch strings.ToUpper(r.Level) { + case "CRITICAL", "ERROR": + errorCount++ + if len(criticalMsgs) < 5 { // Keep first 5 critical messages + criticalMsgs = append(criticalMsgs, r.Message) + } + case "WARNING": + warningCount++ + case "INFO", "NOTICE": + infoCount++ + } + } + + // Determine status + var status happydns.CheckResultStatus + var statusLine string + + if errorCount > 0 { + status = happydns.CheckResultStatusKO + statusLine = fmt.Sprintf("%d error(s), %d warning(s) found", errorCount, warningCount) + if len(criticalMsgs) > 0 { + statusLine += ": " + strings.Join(criticalMsgs[:min(2, len(criticalMsgs))], "; ") + } + } else if warningCount > 0 { + status = happydns.CheckResultStatusWarn + statusLine = fmt.Sprintf("%d warning(s) found", warningCount) + } else { + status = happydns.CheckResultStatusOK + statusLine = fmt.Sprintf("All checks passed (%d checks)", len(results.Results)) + } + + return &happydns.CheckResult{ + Status: status, + StatusLine: statusLine, + Report: results, + }, nil +} diff --git a/internal/api-admin/controller/check_controller.go b/internal/api-admin/controller/check_controller.go new file mode 100644 index 00000000..e5eeb1f6 --- /dev/null +++ b/internal/api-admin/controller/check_controller.go @@ -0,0 +1,211 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package controller + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + apicontroller "git.happydns.org/happyDomain/internal/api/controller" + "git.happydns.org/happyDomain/model" +) + +// CheckerController handles admin-level operations. +// All methods in this controller work with admin-scoped options (nil user/domain/service IDs). +type CheckerController struct { + *apicontroller.BaseCheckerController +} + +func NewCheckerController(checkerService happydns.CheckerUsecase) *CheckerController { + return &CheckerController{ + BaseCheckerController: apicontroller.NewBaseCheckerController(checkerService), + } +} + +// CheckerHandler is a middleware that retrieves a check by name and sets it in the context. +func (uc *CheckerController) CheckerHandler(c *gin.Context) { + cname := c.Param("cname") + + checker, err := uc.BaseCheckerController.GetCheckerService().GetChecker(cname) + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, happydns.ErrorResponse{Message: "Check not found"}) + return + } + + c.Set("checker", checker) + + c.Next() +} + +// CheckerOptionHandler is a middleware that retrieves a specific option and sets it in the context. +func (uc *CheckerController) CheckerOptionHandler(c *gin.Context) { + cname := c.Param("cname") + optname := c.Param("optname") + + opts, err := uc.BaseCheckerController.GetCheckerService().GetCheckerOptions(cname, nil, nil, nil) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: err.Error()}) + return + } + + c.Set("option", (*opts)[optname]) + + c.Next() +} + +// ListCheckers retrieves all available checks. +// +// @Summary List checkers (admin) +// @Schemes +// @Description Returns a list of all available checks with their version information. +// @Tags checks +// @Accept json +// @Produce json +// @Success 200 {object} map[string]happydns.CheckerResponse "Map of checker names to info" +// @Failure 500 {object} happydns.ErrorResponse "Internal server error" +// @Router /checks [get] +func (uc *CheckerController) ListCheckers(c *gin.Context) { + uc.BaseCheckerController.ListCheckers(c) +} + +// GetCheckerStatus retrieves the status and available options for a check. +// +// @Summary Get check info +// @Schemes +// @Description Retrieves the status information and available options for a specific checker. +// @Tags checks +// @Accept json +// @Produce json +// @Param cname path string true "Checker name" +// @Success 200 {object} happydns.CheckerResponse "Checker status with version info and available options" +// @Failure 404 {object} happydns.ErrorResponse "Checker not found" +// @Router /checks/{cname} [get] +func (uc *CheckerController) GetCheckerStatus(c *gin.Context) { + uc.BaseCheckerController.GetCheckerStatus(c) +} + +// GetCheckerOptions retrieves all options for a check. +// +// @Summary Get check options (admin) +// @Schemes +// @Description Retrieves all configuration options for a specific check. +// @Tags checks +// @Accept json +// @Produce json +// @Param cname path string true "Checker name" +// @Success 200 {object} happydns.CheckerOptions "Checker options as key-value pairs" +// @Failure 404 {object} happydns.ErrorResponse "Checker not found" +// @Failure 500 {object} happydns.ErrorResponse "Internal server error" +// @Router /checks/{cname}/options [get] +func (uc *CheckerController) GetCheckerOptions(c *gin.Context) { + cname := c.Param("cname") + + // Get admin-level options (nil user/domain/service IDs) + uc.GetCheckerOptionsWithScope(c, cname, nil, nil, nil) +} + +// AddCheckerOptions adds or overwrites specific admin-level options for a check. +// +// @Summary Add checker options +// @Schemes +// @Description Adds or overwrites specific configuration options for a checker without affecting other options. +// @Tags checks +// @Accept json +// @Produce json +// @Param cname path string true "Checker name" +// @Param body body happydns.SetCheckerOptionsRequest true "Options to add or overwrite" +// @Success 200 {object} bool "Success status" +// @Failure 400 {object} happydns.ErrorResponse "Invalid request body" +// @Failure 404 {object} happydns.ErrorResponse "Checker not found" +// @Failure 500 {object} happydns.ErrorResponse "Internal server error" +// @Router /checks/{cname}/options [post] +func (uc *CheckerController) AddCheckerOptions(c *gin.Context) { + cname := c.Param("cname") + + // Add admin-level options (nil user/domain/service IDs) + uc.AddCheckerOptionsWithScope(c, cname, nil, nil, nil) +} + +// ChangeCheckerOptions replaces all options for a checker. +// +// @Summary Replace checker options (admin) +// @Schemes +// @Description Replaces all configuration options for a check with the provided options. +// @Tags checks +// @Accept json +// @Produce json +// @Param cname path string true "Checker name" +// @Param body body happydns.SetCheckerOptionsRequest true "New complete set of options" +// @Success 200 {object} bool "Success status" +// @Failure 400 {object} happydns.ErrorResponse "Invalid request body" +// @Failure 404 {object} happydns.ErrorResponse "Checker not found" +// @Failure 500 {object} happydns.ErrorResponse "Internal server error" +// @Router /checks/{cname}/options [put] +func (uc *CheckerController) ChangeCheckerOptions(c *gin.Context) { + cname := c.Param("cname") + + // Replace admin-level options (nil user/domain/service IDs) + uc.ChangeCheckerOptionsWithScope(c, cname, nil, nil, nil) +} + +// GetCheckerOption retrieves a specific option value for a checker. +// +// @Summary Get checker option (admin) +// @Schemes +// @Description Retrieves the value of a specific configuration option for a checker. +// @Tags checks +// @Accept json +// @Produce json +// @Param cname path string true "Checker name" +// @Param optname path string true "Option name" +// @Success 200 {object} object "Option value (type varies)" +// @Failure 404 {object} happydns.ErrorResponse "Checker not found" +// @Failure 500 {object} happydns.ErrorResponse "Internal server error" +// @Router /checks/{cname}/options/{optname} [get] +func (uc *CheckerController) GetCheckerOption(c *gin.Context) { + uc.GetCheckerOptionValue(c) +} + +// SetCheckerOption sets or updates a specific option value for a checker. +// +// @Summary Set checker option (admin) +// @Schemes +// @Description Sets or updates the value of a specific configuration option for a checker. +// @Tags checks +// @Accept json +// @Produce json +// @Param cname path string true "Checker name" +// @Param optname path string true "Option name" +// @Param body body object true "Option value (type varies by option)" +// @Success 200 {object} bool "Success status" +// @Failure 400 {object} happydns.ErrorResponse "Invalid request body" +// @Failure 404 {object} happydns.ErrorResponse "Checker not found" +// @Failure 500 {object} happydns.ErrorResponse "Internal server error" +// @Router /checks/{cname}/options/{optname} [put] +func (uc *CheckerController) SetCheckerOption(c *gin.Context) { + cname := c.Param("cname") + optname := c.Param("optname") + + // Set admin-level option (nil user/domain/service IDs) + uc.SetCheckerOptionWithScope(c, cname, optname, nil, nil, nil) +} diff --git a/internal/api-admin/controller/scheduler_controller.go b/internal/api-admin/controller/scheduler_controller.go new file mode 100644 index 00000000..a029c1f0 --- /dev/null +++ b/internal/api-admin/controller/scheduler_controller.go @@ -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 . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package controller + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "git.happydns.org/happyDomain/model" +) + +// AdminSchedulerController handles admin operations on the test scheduler +type AdminSchedulerController struct { + scheduler happydns.SchedulerUsecase +} + +func NewAdminSchedulerController(scheduler happydns.SchedulerUsecase) *AdminSchedulerController { + return &AdminSchedulerController{scheduler: scheduler} +} + +// GetSchedulerStatus returns the current scheduler state +// +// @Summary Get scheduler status +// @Description Returns the current state of the test scheduler including worker count, queue size, and upcoming schedules +// @Tags scheduler +// @Produce json +// @Success 200 {object} happydns.SchedulerStatus +// @Router /scheduler [get] +func (ctrl *AdminSchedulerController) GetSchedulerStatus(c *gin.Context) { + c.JSON(http.StatusOK, ctrl.scheduler.GetSchedulerStatus()) +} + +// EnableScheduler enables the test scheduler at runtime +// +// @Summary Enable scheduler +// @Description Enables the test scheduler at runtime without restarting the server +// @Tags scheduler +// @Success 200 {object} happydns.SchedulerStatus +// @Failure 500 {object} happydns.ErrorResponse +// @Router /scheduler/enable [post] +func (ctrl *AdminSchedulerController) EnableScheduler(c *gin.Context) { + if err := ctrl.scheduler.SetEnabled(true); err != nil { + c.JSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: err.Error()}) + return + } + c.JSON(http.StatusOK, ctrl.scheduler.GetSchedulerStatus()) +} + +// DisableScheduler disables the test scheduler at runtime +// +// @Summary Disable scheduler +// @Description Disables the test scheduler at runtime without restarting the server +// @Tags scheduler +// @Success 200 {object} happydns.SchedulerStatus +// @Failure 500 {object} happydns.ErrorResponse +// @Router /scheduler/disable [post] +func (ctrl *AdminSchedulerController) DisableScheduler(c *gin.Context) { + if err := ctrl.scheduler.SetEnabled(false); err != nil { + c.JSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: err.Error()}) + return + } + c.JSON(http.StatusOK, ctrl.scheduler.GetSchedulerStatus()) +} + +// RescheduleUpcoming randomizes the next run time of all enabled schedules +// within their respective intervals to spread load evenly. +// +// @Summary Reschedule upcoming tests +// @Description Randomizes the next run time of all enabled schedules within their intervals to spread load +// @Tags scheduler +// @Produce json +// @Success 200 {object} map[string]int +// @Failure 500 {object} happydns.ErrorResponse +// @Router /scheduler/reschedule-upcoming [post] +func (ctrl *AdminSchedulerController) RescheduleUpcoming(c *gin.Context) { + n, err := ctrl.scheduler.RescheduleUpcomingChecks() + if err != nil { + c.JSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"rescheduled": n}) +} diff --git a/internal/api-admin/route/check.go b/internal/api-admin/route/check.go new file mode 100644 index 00000000..139f4c50 --- /dev/null +++ b/internal/api-admin/route/check.go @@ -0,0 +1,51 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package route + +import ( + "github.com/gin-gonic/gin" + + "git.happydns.org/happyDomain/internal/api-admin/controller" +) + +func declareChecksRoutes(router *gin.RouterGroup, dep Dependencies) { + cc := controller.NewCheckerController(dep.Checker) + + apiChecksRoutes := router.Group("/checks") + + apiChecksRoutes.GET("", cc.ListCheckers) + + apiCheckerRoutes := apiChecksRoutes.Group("/:cname") + apiCheckerRoutes.Use(cc.CheckerHandler) + + apiCheckerRoutes.GET("", cc.GetCheckerStatus) + //apiCheckerRoutes.POST("", tpc.ChangeCheckerStatus) + + apiCheckerRoutes.GET("/options", cc.GetCheckerOptions) + apiCheckerRoutes.POST("/options", cc.AddCheckerOptions) + apiCheckerRoutes.PUT("/options", cc.ChangeCheckerOptions) + + apiCheckerOptionsRoutes := apiCheckerRoutes.Group("/options/:optname") + apiCheckerOptionsRoutes.Use(cc.CheckerOptionHandler) + apiCheckerOptionsRoutes.GET("", cc.GetCheckerOption) + apiCheckerOptionsRoutes.PUT("", cc.SetCheckerOption) +} diff --git a/internal/api-admin/route/route.go b/internal/api-admin/route/route.go index 3a328b2e..a1a1586d 100644 --- a/internal/api-admin/route/route.go +++ b/internal/api-admin/route/route.go @@ -32,6 +32,8 @@ import ( // Dependencies holds all use cases required to register the admin API routes. type Dependencies struct { AuthUser happydns.AuthUserUsecase + Checker happydns.CheckerUsecase + CheckScheduler happydns.SchedulerUsecase Domain happydns.DomainUsecase Provider happydns.ProviderUsecase RemoteZoneImporter happydns.RemoteZoneImporterUsecase @@ -48,7 +50,9 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.Engine, s storage.Storage, declareBackupRoutes(cfg, apiRoutes, s) declareDomainRoutes(apiRoutes, dep, s) + declareChecksRoutes(apiRoutes, dep) declareProviderRoutes(apiRoutes, dep, s) + declareSchedulerRoutes(apiRoutes, dep) declareSessionsRoutes(cfg, apiRoutes, s) declareUserAuthsRoutes(apiRoutes, dep, s) declareUsersRoutes(apiRoutes, dep, s) diff --git a/internal/api-admin/route/scheduler.go b/internal/api-admin/route/scheduler.go new file mode 100644 index 00000000..d7d39d91 --- /dev/null +++ b/internal/api-admin/route/scheduler.go @@ -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 . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package route + +import ( + "github.com/gin-gonic/gin" + + "git.happydns.org/happyDomain/internal/api-admin/controller" +) + +func declareSchedulerRoutes(router *gin.RouterGroup, dep Dependencies) { + ctrl := controller.NewAdminSchedulerController(dep.CheckScheduler) + + schedulerRoute := router.Group("/scheduler") + schedulerRoute.GET("", ctrl.GetSchedulerStatus) + schedulerRoute.POST("/enable", ctrl.EnableScheduler) + schedulerRoute.POST("/disable", ctrl.DisableScheduler) + schedulerRoute.POST("/reschedule-upcoming", ctrl.RescheduleUpcoming) +} diff --git a/internal/api/controller/check_base_controller.go b/internal/api/controller/check_base_controller.go new file mode 100644 index 00000000..1baac486 --- /dev/null +++ b/internal/api/controller/check_base_controller.go @@ -0,0 +1,137 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package controller + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "git.happydns.org/happyDomain/internal/api/middleware" + "git.happydns.org/happyDomain/model" +) + +// BaseCheckerController contains shared functionality for check controllers. +// It provides common methods that can be used by both admin and user-scoped controllers. +type BaseCheckerController struct { + checkerService happydns.CheckerUsecase +} + +func NewBaseCheckerController(checkerService happydns.CheckerUsecase) *BaseCheckerController { + return &BaseCheckerController{ + checkerService, + } +} + +// GetCheckerService returns the check service for use by derived controllers. +func (bc *BaseCheckerController) GetCheckerService() happydns.CheckerUsecase { + return bc.checkerService +} + +// ListCheckers retrieves all available checks. +func (bc *BaseCheckerController) ListCheckers(c *gin.Context) { + checkers, err := bc.checkerService.ListCheckers() + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + + res := map[string]happydns.CheckerResponse{} + + for name, checker := range *checkers { + res[name] = happydns.CheckerResponse{ + ID: name, + Name: checker.Name(), + Availability: checker.Availability(), + Options: checker.Options(), + } + } + + happydns.ApiResponse(c, res, nil) +} + +// GetCheckerStatus retrieves the status and available options for a check. +func (bc *BaseCheckerController) GetCheckerStatus(c *gin.Context) { + checker := c.MustGet("checker").(happydns.Checker) + + c.JSON(http.StatusOK, happydns.CheckerResponse{ + ID: checker.ID(), + Name: checker.Name(), + Availability: checker.Availability(), + Options: checker.Options(), + }) +} + +// GetCheckerOptionsWithScope retrieves all options for a check with the given scope. +func (bc *BaseCheckerController) GetCheckerOptionsWithScope(c *gin.Context, cname string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) { + opts, err := bc.checkerService.GetCheckerOptions(cname, userId, domainId, serviceId) + happydns.ApiResponse(c, opts, err) +} + +// AddCheckerOptionsWithScope adds or overwrites specific options for a check with the given scope. +func (bc *BaseCheckerController) AddCheckerOptionsWithScope(c *gin.Context, cname string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) { + var req happydns.SetCheckerOptionsRequest + err := c.ShouldBindJSON(&req) + if err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err) + return + } + + err = bc.checkerService.OverwriteSomeCheckerOptions(cname, userId, domainId, serviceId, req.Options) + happydns.ApiResponse(c, true, err) +} + +// ChangeCheckerOptionsWithScope replaces all options for a check with the given scope. +func (bc *BaseCheckerController) ChangeCheckerOptionsWithScope(c *gin.Context, cname string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) { + var req happydns.SetCheckerOptionsRequest + err := c.ShouldBindJSON(&req) + if err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err) + return + } + + err = bc.checkerService.SetCheckerOptions(cname, userId, domainId, serviceId, req.Options) + happydns.ApiResponse(c, true, err) +} + +// GetCheckerOptionValue retrieves a specific option value from the context. +func (bc *BaseCheckerController) GetCheckerOptionValue(c *gin.Context) { + opt := c.MustGet("option") + + happydns.ApiResponse(c, opt, nil) +} + +// SetCheckerOptionWithScope sets or updates a specific option value for a check with the given scope. +func (bc *BaseCheckerController) SetCheckerOptionWithScope(c *gin.Context, cname string, optname string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) { + var req any + err := c.ShouldBindJSON(&req) + if err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err) + return + } + + po := happydns.CheckerOptions{} + po[optname] = req + + err = bc.checkerService.OverwriteSomeCheckerOptions(cname, userId, domainId, serviceId, po) + happydns.ApiResponse(c, true, err) +} diff --git a/internal/api/controller/check_controller.go b/internal/api/controller/check_controller.go new file mode 100644 index 00000000..e721b502 --- /dev/null +++ b/internal/api/controller/check_controller.go @@ -0,0 +1,211 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package controller + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "git.happydns.org/happyDomain/model" +) + +// CheckerController handles user-scoped check operations for the main API. +// All methods work with options scoped to the authenticated user. +type CheckerController struct { + *BaseCheckerController +} + +func NewCheckerController(checkerService happydns.CheckerUsecase) *CheckerController { + return &CheckerController{ + BaseCheckerController: NewBaseCheckerController(checkerService), + } +} + +// ListCheckers retrieves all available checks. +// +// @Summary List all checks +// @Schemes +// @Description Returns a list of all available checks with their version information. +// @Tags checks +// @Accept json +// @Produce json +// @Success 200 {object} map[string]happydns.CheckerResponse "Map of check names to version info" +// @Failure 500 {object} happydns.ErrorResponse "Internal server error" +// @Router /checks [get] +func (uc *CheckerController) ListCheckers(c *gin.Context) { + uc.BaseCheckerController.ListCheckers(c) +} + +// GetCheckerStatus retrieves the status and available options for a check. +// +// @Summary Get check status +// @Schemes +// @Description Retrieves the status information and available options for a specific check. +// @Tags checks +// @Accept json +// @Produce json +// @Param cid path string true "Check name" +// @Success 200 {object} happydns.CheckerResponse "Check status with version info and available options" +// @Failure 404 {object} happydns.ErrorResponse "Check not found" +// @Router /checks/{cid} [get] +func (uc *CheckerController) GetCheckerStatus(c *gin.Context) { + uc.BaseCheckerController.GetCheckerStatus(c) +} + +// CheckerHandler is a middleware that retrieves a check by name and sets it in the context. +func (uc *CheckerController) CheckerHandler(c *gin.Context) { + cname := c.Param("cid") + + check, err := uc.checkerService.GetChecker(cname) + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, happydns.ErrorResponse{Message: "Check not found"}) + return + } + + c.Set("check", check) + + c.Next() +} + +// CheckerOptionHandler is a middleware that retrieves a specific check option for the authenticated user and sets it in the context. +func (uc *CheckerController) CheckerOptionHandler(c *gin.Context) { + user := c.MustGet("LoggedUser").(*happydns.User) + cname := c.Param("cid") + optname := c.Param("optname") + + opts, err := uc.checkerService.GetCheckerOptions(cname, &user.Id, nil, nil) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: err.Error()}) + return + } + + c.Set("option", (*opts)[optname]) + + c.Next() +} + +// GetCheckOptions retrieves all options for a check for the authenticated user. +// +// @Summary Get check options +// @Schemes +// @Description Retrieves all configuration options for a specific check for the authenticated user. +// @Tags checks +// @Accept json +// @Produce json +// @Param cid path string true "Check name" +// @Success 200 {object} happydns.CheckerOptions "Check options as key-value pairs" +// @Failure 404 {object} happydns.ErrorResponse "Check not found" +// @Failure 500 {object} happydns.ErrorResponse "Internal server error" +// @Router /checks/{cid}/options [get] +func (uc *CheckerController) GetCheckerOptions(c *gin.Context) { + user := c.MustGet("LoggedUser").(*happydns.User) + cname := c.Param("cid") + + uc.GetCheckerOptionsWithScope(c, cname, &user.Id, nil, nil) +} + +// AddCheckerOptions adds or overwrites specific options for a check for the authenticated user. +// +// @Summary Add check options +// @Schemes +// @Description Adds or overwrites specific configuration options for a check for the authenticated user without affecting other options. +// @Tags checks +// @Accept json +// @Produce json +// @Param cid path string true "Check name" +// @Param body body happydns.SetCheckerOptionsRequest true "Options to add or overwrite" +// @Success 200 {object} bool "Success status" +// @Failure 400 {object} happydns.ErrorResponse "Invalid request body" +// @Failure 404 {object} happydns.ErrorResponse "Check not found" +// @Failure 500 {object} happydns.ErrorResponse "Internal server error" +// @Router /checks/{cid}/options [post] +func (uc *CheckerController) AddCheckerOptions(c *gin.Context) { + user := c.MustGet("LoggedUser").(*happydns.User) + cname := c.Param("cid") + + uc.AddCheckerOptionsWithScope(c, cname, &user.Id, nil, nil) +} + +// ChangeCheckerOptions replaces all options for a check for the authenticated user. +// +// @Summary Replace check options +// @Schemes +// @Description Replaces all configuration options for a check for the authenticated user with the provided options. +// @Tags checks +// @Accept json +// @Produce json +// @Param cid path string true "Checker name" +// @Param body body happydns.SetCheckerOptionsRequest true "New complete set of options" +// @Success 200 {object} bool "Success status" +// @Failure 400 {object} happydns.ErrorResponse "Invalid request body" +// @Failure 404 {object} happydns.ErrorResponse "Checker not found" +// @Failure 500 {object} happydns.ErrorResponse "Internal server error" +// @Router /checks/{cid}/options [put] +func (uc *CheckerController) ChangeCheckerOptions(c *gin.Context) { + user := c.MustGet("LoggedUser").(*happydns.User) + cname := c.Param("cid") + + uc.ChangeCheckerOptionsWithScope(c, cname, &user.Id, nil, nil) +} + +// GetCheckerOption retrieves a specific option value for a check for the authenticated user. +// +// @Summary Get check option +// @Schemes +// @Description Retrieves the value of a specific configuration option for a check for the authenticated user. +// @Tags checks +// @Accept json +// @Produce json +// @Param cid path string true "Check name" +// @Param optname path string true "Option name" +// @Success 200 {object} object "Option value (type varies)" +// @Failure 404 {object} happydns.ErrorResponse "Check not found" +// @Failure 500 {object} happydns.ErrorResponse "Internal server error" +// @Router /checks/{cid}/options/{optname} [get] +func (uc *CheckerController) GetCheckerOption(c *gin.Context) { + uc.GetCheckerOptionValue(c) +} + +// SetCheckerOption sets or updates a specific option value for a check for the authenticated user. +// +// @Summary Set check option +// @Schemes +// @Description Sets or updates the value of a specific configuration option for a check for the authenticated user. +// @Tags checks +// @Accept json +// @Produce json +// @Param cid path string true "Check name" +// @Param optname path string true "Option name" +// @Param body body object true "Option value (type varies by option)" +// @Success 200 {object} bool "Success status" +// @Failure 400 {object} happydns.ErrorResponse "Invalid request body" +// @Failure 404 {object} happydns.ErrorResponse "Check not found" +// @Failure 500 {object} happydns.ErrorResponse "Internal server error" +// @Router /checks/{cid}/options/{optname} [put] +func (uc *CheckerController) SetCheckerOption(c *gin.Context) { + user := c.MustGet("LoggedUser").(*happydns.User) + cname := c.Param("cid") + optname := c.Param("optname") + + uc.SetCheckerOptionWithScope(c, cname, optname, &user.Id, nil, nil) +} diff --git a/internal/api/controller/checkresult_controller.go b/internal/api/controller/checkresult_controller.go new file mode 100644 index 00000000..fb79ade4 --- /dev/null +++ b/internal/api/controller/checkresult_controller.go @@ -0,0 +1,525 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package controller + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + + "git.happydns.org/happyDomain/internal/api/middleware" + "git.happydns.org/happyDomain/model" +) + +// CheckResultController handles check result operations +type CheckResultController struct { + scope happydns.CheckScopeType + checkerUC happydns.CheckerUsecase + checkResultUC happydns.CheckResultUsecase + checkerScheduleUC happydns.CheckerScheduleUsecase + checkScheduler happydns.SchedulerUsecase +} + +func NewCheckResultController( + scope happydns.CheckScopeType, + checkerUC happydns.CheckerUsecase, + checkResultUC happydns.CheckResultUsecase, + checkerScheduleUC happydns.CheckerScheduleUsecase, + checkScheduler happydns.SchedulerUsecase, +) *CheckResultController { + return &CheckResultController{ + scope: scope, + checkerUC: checkerUC, + checkResultUC: checkResultUC, + checkerScheduleUC: checkerScheduleUC, + checkScheduler: checkScheduler, + } +} + +// getTargetFromContext extracts the target ID from context based on scope +func (tc *CheckResultController) getTargetFromContext(c *gin.Context) (happydns.Identifier, error) { + switch tc.scope { + case happydns.CheckScopeUser: + user := c.MustGet("user").(*happydns.User) + return user.Id, nil + case happydns.CheckScopeDomain: + domain := c.MustGet("domain").(*happydns.Domain) + return domain.Id, nil + case happydns.CheckScopeService: + // Services are stored by ID in context + serviceID := c.MustGet("serviceid").(happydns.Identifier) + return serviceID, nil + default: + return happydns.Identifier{}, fmt.Errorf("unsupported scope") + } +} + +// ListAvailableChecks lists all available check plugins for the target scope +// +// @Summary List available checks +// @Description Retrieves all available check plugins for the target scope with their last execution status if enabled +// @Tags checks +// @Produce json +// @Param domain path string true "Domain identifier" +// @Success 200 {array} object "List of available checks" +// @Failure 500 {object} happydns.ErrorResponse +// @Router /domains/{domain}/checks [get] +func (tc *CheckResultController) ListAvailableChecks(c *gin.Context) { + targetID, err := tc.getTargetFromContext(c) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + + // Get all check plugins + plugins, err := tc.checkerUC.ListCheckers() + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + + // Get schedules for this target + schedules, err := tc.checkerScheduleUC.ListSchedulesByTarget(tc.scope, targetID) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + + // Build schedule map + scheduleMap := make(map[string]*happydns.CheckerSchedule) + for _, sched := range schedules { + scheduleMap[sched.CheckerName] = sched + } + + // Build response with last results + var checks []happydns.CheckerStatus + for checkername, check := range *plugins { + // Filter plugins by scope + if tc.scope == happydns.CheckScopeDomain && !check.Availability().ApplyToDomain { + continue + } + if tc.scope == happydns.CheckScopeService && !check.Availability().ApplyToService { + continue + } + + info := happydns.CheckerStatus{ + CheckerName: checkername, + Enabled: true, // enabled by default unless explicitly disabled via a schedule + } + + // Check if there's a schedule + if sched, ok := scheduleMap[checkername]; ok { + info.Enabled = sched.Enabled + info.Schedule = sched + + // Get last result + results, err := tc.checkResultUC.ListCheckResultsByTarget(checkername, tc.scope, targetID, 1) + if err == nil && len(results) > 0 { + info.LastResult = results[0] + } + } + + checks = append(checks, info) + } + + c.JSON(http.StatusOK, checks) +} + +// ListLatestCheckResults retrieves the lacheck check results for a specific plugin +// +// @Summary Get lacheck check results +// @Description Retrieves the 5 most recent check results for a specific plugin and target +// @Tags checks +// @Produce json +// @Param domain path string true "Domain identifier" +// @Param cname path string true "Check plugin name" +// @Success 200 {array} happydns.CheckResult +// @Failure 500 {object} happydns.ErrorResponse +// @Router /domains/{domain}/checks/{cname} [get] +func (tc *CheckResultController) ListLatestCheckResults(c *gin.Context) { + checkName := c.Param("cname") + targetID, err := tc.getTargetFromContext(c) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + + results, err := tc.checkResultUC.ListCheckResultsByTarget(checkName, tc.scope, targetID, 5) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + + c.JSON(http.StatusOK, results) +} + +// TriggerCheck triggers an on-demand check execution +// +// @Summary Trigger check execution +// @Description Triggers an immediate check execution and returns the execution ID +// @Tags checks +// @Accept json +// @Produce json +// @Param domain path string true "Domain identifier" +// @Param cname path string true "Check plugin name" +// @Param body body object false "Optional: Plugin options" +// @Success 202 {object} object{execution_id=string} +// @Failure 400 {object} happydns.ErrorResponse +// @Failure 500 {object} happydns.ErrorResponse +// @Router /domains/{domain}/checks/{cname} [post] +func (tc *CheckResultController) TriggerCheck(c *gin.Context) { + user := middleware.MyUser(c) + checkName := c.Param("cname") + targetID, err := tc.getTargetFromContext(c) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + + // Parse run options + var options happydns.SetCheckerOptionsRequest + if err = c.ShouldBindJSON(&options); err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err) + return + } + + // Merge options with upper levels (user, domain, service) + var domainID, serviceID *happydns.Identifier + switch tc.scope { + case happydns.CheckScopeDomain: + domainID = &targetID + case happydns.CheckScopeService: + serviceID = &targetID + } + + mergedOptions, err := tc.checkerUC.BuildMergedCheckerOptions(checkName, &user.Id, domainID, serviceID, options.Options) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + + // Trigger the test via scheduler (returns error if scheduler is disabled) + executionID, err := tc.checkScheduler.TriggerOnDemandCheck(checkName, tc.scope, targetID, user.Id, mergedOptions) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + + c.JSON(http.StatusAccepted, gin.H{"execution_id": executionID.String()}) +} + +// GetCheckerOptions retrieves plugin options for the target scope +// +// @Summary Get check plugin options +// @Description Retrieves configuration options for a checker at the target scope +// @Tags checks +// @Produce json +// @Param domain path string true "Domain identifier" +// @Param cname path string true "Check plugin name" +// @Success 200 {object} happydns.CheckerOptions +// @Failure 500 {object} happydns.ErrorResponse +// @Router /domains/{domain}/checks/{cname}/options [get] +func (tc *CheckResultController) GetCheckerOptions(c *gin.Context) { + user := middleware.MyUser(c) + checkName := c.Param("cname") + targetID, err := tc.getTargetFromContext(c) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + + var domainID, serviceID *happydns.Identifier + switch tc.scope { + case happydns.CheckScopeDomain: + domainID = &targetID + case happydns.CheckScopeService: + serviceID = &targetID + } + + opts, err := tc.checkerUC.GetStoredCheckerOptionsNoDefault(checkName, &user.Id, domainID, serviceID) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + + c.JSON(http.StatusOK, opts) +} + +// AddCheckerOptions adds or overwrites specific options +// +// @Summary Add check plugin options +// @Description Adds or overwrites specific options for a check plugin at the target scope +// @Tags checks +// @Accept json +// @Produce json +// @Param domain path string true "Domain identifier" +// @Param cname path string true "Check plugin name" +// @Param body body happydns.CheckerOptions true "Options to add" +// @Success 200 {object} bool +// @Failure 400 {object} happydns.ErrorResponse +// @Failure 500 {object} happydns.ErrorResponse +// @Router /domains/{domain}/checks/{cname}/options [post] +func (tc *CheckResultController) AddCheckerOptions(c *gin.Context) { + user := middleware.MyUser(c) + checkName := c.Param("cname") + targetID, err := tc.getTargetFromContext(c) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + + var options happydns.CheckerOptions + if err = c.ShouldBindJSON(&options); err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err) + return + } + + var domainID, serviceID *happydns.Identifier + switch tc.scope { + case happydns.CheckScopeDomain: + domainID = &targetID + case happydns.CheckScopeService: + serviceID = &targetID + } + + err = tc.checkerUC.OverwriteSomeCheckerOptions(checkName, &user.Id, domainID, serviceID, options) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + + c.JSON(http.StatusOK, true) +} + +// ChangeCheckerOptions replaces all options +// +// @Summary Replace check plugin options +// @Description Replaces all options for a check plugin at the target scope +// @Tags checks +// @Accept json +// @Produce json +// @Param domain path string true "Domain identifier" +// @Param cname path string true "Check plugin name" +// @Param body body happydns.CheckerOptions true "New complete options" +// @Success 200 {object} bool +// @Failure 400 {object} happydns.ErrorResponse +// @Failure 500 {object} happydns.ErrorResponse +// @Router /domains/{domain}/checks/{cname}/options [put] +func (tc *CheckResultController) ChangeCheckerOptions(c *gin.Context) { + user := middleware.MyUser(c) + checkName := c.Param("cname") + targetID, err := tc.getTargetFromContext(c) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + + var options happydns.CheckerOptions + if err = c.ShouldBindJSON(&options); err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err) + return + } + + var domainID, serviceID *happydns.Identifier + switch tc.scope { + case happydns.CheckScopeDomain: + domainID = &targetID + case happydns.CheckScopeService: + serviceID = &targetID + } + + err = tc.checkerUC.SetCheckerOptions(checkName, &user.Id, domainID, serviceID, options) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + + c.JSON(http.StatusOK, true) +} + +// GetCheckExecutionStatus retrieves the status of a check execution +// +// @Summary Get check execution status +// @Description Retrieves the current status of a check execution +// @Tags checks +// @Produce json +// @Param domain path string true "Domain identifier" +// @Param cname path string true "Check plugin name" +// @Param execution_id path string true "Execution ID" +// @Success 200 {object} happydns.CheckExecution +// @Failure 404 {object} happydns.ErrorResponse +// @Failure 500 {object} happydns.ErrorResponse +// @Router /domains/{domain}/checks/{cname}/executions/{execution_id} [get] +func (tc *CheckResultController) GetCheckExecutionStatus(c *gin.Context) { + executionIDStr := c.Param("execution_id") + executionID, err := happydns.NewIdentifierFromString(executionIDStr) + if err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid execution ID")) + return + } + + execution, err := tc.checkResultUC.GetCheckExecution(executionID) + if err != nil { + middleware.ErrorResponse(c, http.StatusNotFound, err) + return + } + + c.JSON(http.StatusOK, execution) +} + +// ListCheckResults lists all results for a check plugin +// +// @Summary List check results +// @Description Lists all check results for a specific check plugin and target +// @Tags checks +// @Produce json +// @Param domain path string true "Domain identifier" +// @Param cname path string true "Check plugin name" +// @Param limit query int false "Maximum number of results to return (default: 10)" +// @Success 200 {array} happydns.CheckResult +// @Failure 500 {object} happydns.ErrorResponse +// @Router /domains/{domain}/checks/{cname}/results [get] +func (tc *CheckResultController) ListCheckResults(c *gin.Context) { + checkName := c.Param("cname") + targetID, err := tc.getTargetFromContext(c) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + + // Parse limit parameter + limit := 10 + if limitStr := c.Query("limit"); limitStr != "" { + fmt.Sscanf(limitStr, "%d", &limit) + } + + results, err := tc.checkResultUC.ListCheckResultsByTarget(checkName, tc.scope, targetID, limit) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + + c.JSON(http.StatusOK, results) +} + +// DropCheckResults deletes all results for a check plugin +// +// @Summary Delete all check results +// @Description Deletes all check results for a specific check plugin and target +// @Tags checks +// @Produce json +// @Param domain path string true "Domain identifier" +// @Param cname path string true "Check plugin name" +// @Success 204 "No Content" +// @Failure 500 {object} happydns.ErrorResponse +// @Router /domains/{domain}/checks/{cname}/results [delete] +func (tc *CheckResultController) DropCheckResults(c *gin.Context) { + checkName := c.Param("cname") + targetID, err := tc.getTargetFromContext(c) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + + err = tc.checkResultUC.DeleteAllCheckResults(checkName, tc.scope, targetID) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + + c.Status(http.StatusNoContent) +} + +// GetCheckPluginResult retrieves a specific check result +// +// @Summary Get check result +// @Description Retrieves a specific check result by ID +// @Tags checks +// @Produce json +// @Param domain path string true "Domain identifier" +// @Param cname path string true "Check plugin name" +// @Param result_id path string true "Result ID" +// @Success 200 {object} happydns.CheckResult +// @Failure 404 {object} happydns.ErrorResponse +// @Failure 500 {object} happydns.ErrorResponse +// @Router /domains/{domain}/checks/{cname}/results/{result_id} [get] +func (tc *CheckResultController) GetCheckResult(c *gin.Context) { + checkName := c.Param("cname") + resultIDStr := c.Param("result_id") + targetID, err := tc.getTargetFromContext(c) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + + resultID, err := happydns.NewIdentifierFromString(resultIDStr) + if err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid result ID")) + return + } + + result, err := tc.checkResultUC.GetCheckResult(checkName, tc.scope, targetID, resultID) + if err != nil { + middleware.ErrorResponse(c, http.StatusNotFound, err) + return + } + + c.JSON(http.StatusOK, result) +} + +// DropCheckResult deletes a specific check result +// +// @Summary Delete check result +// @Description Deletes a specific check result by ID +// @Tags checks +// @Produce json +// @Param domain path string true "Domain identifier" +// @Param cname path string true "Check plugin name" +// @Param result_id path string true "Result ID" +// @Success 204 "No Content" +// @Failure 404 {object} happydns.ErrorResponse +// @Failure 500 {object} happydns.ErrorResponse +// @Router /domains/{domain}/checks/{cname}/results/{result_id} [delete] +func (tc *CheckResultController) DropCheckResult(c *gin.Context) { + checkName := c.Param("cname") + resultIDStr := c.Param("result_id") + targetID, err := tc.getTargetFromContext(c) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + + resultID, err := happydns.NewIdentifierFromString(resultIDStr) + if err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid result ID")) + return + } + + err = tc.checkResultUC.DeleteCheckResult(checkName, tc.scope, targetID, resultID) + if err != nil { + middleware.ErrorResponse(c, http.StatusNotFound, err) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/internal/api/controller/testschedule_controller.go b/internal/api/controller/testschedule_controller.go new file mode 100644 index 00000000..5b25b3c3 --- /dev/null +++ b/internal/api/controller/testschedule_controller.go @@ -0,0 +1,231 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package controller + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + + "git.happydns.org/happyDomain/internal/api/middleware" + "git.happydns.org/happyDomain/model" +) + +// CheckerScheduleController handles test schedule operations +type CheckerScheduleController struct { + testScheduleUC happydns.CheckerScheduleUsecase +} + +func NewCheckerScheduleController(testScheduleUC happydns.CheckerScheduleUsecase) *CheckerScheduleController { + return &CheckerScheduleController{ + testScheduleUC: testScheduleUC, + } +} + +// ListCheckerSchedules retrieves schedules for the authenticated user +// +// @Summary List test schedules +// @Description Retrieves test schedules for the authenticated user with optional pagination +// @Tags test-schedules +// @Produce json +// @Param limit query int false "Maximum number of schedules to return (0 = all)" +// @Param offset query int false "Number of schedules to skip (default: 0)" +// @Success 200 {array} happydns.CheckerSchedule +// @Failure 500 {object} happydns.ErrorResponse +// @Router /plugins/tests/schedules [get] +func (tc *CheckerScheduleController) ListCheckerSchedules(c *gin.Context) { + user := middleware.MyUser(c) + + schedules, err := tc.testScheduleUC.ListUserSchedules(user.Id) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + + // Apply pagination + limit := 0 + offset := 0 + fmt.Sscanf(c.Query("limit"), "%d", &limit) + fmt.Sscanf(c.Query("offset"), "%d", &offset) + + if offset > len(schedules) { + offset = len(schedules) + } + schedules = schedules[offset:] + if limit > 0 && len(schedules) > limit { + schedules = schedules[:limit] + } + + c.JSON(http.StatusOK, schedules) +} + +// CreateCheckerSchedule creates a new test schedule +// +// @Summary Create test schedule +// @Description Creates a new test schedule for the authenticated user +// @Tags test-schedules +// @Accept json +// @Produce json +// @Param body body happydns.CheckerSchedule true "Check schedule to create" +// @Success 201 {object} happydns.CheckerSchedule +// @Failure 400 {object} happydns.ErrorResponse +// @Failure 500 {object} happydns.ErrorResponse +// @Router /plugins/tests/schedules [post] +func (tc *CheckerScheduleController) CreateCheckerSchedule(c *gin.Context) { + user := middleware.MyUser(c) + + var schedule happydns.CheckerSchedule + if err := c.ShouldBindJSON(&schedule); err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err) + return + } + + // Set user ID + schedule.OwnerId = user.Id + + if err := tc.testScheduleUC.CreateSchedule(&schedule); err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + + c.JSON(http.StatusCreated, schedule) +} + +// GetCheckerSchedule retrieves a specific schedule +// +// @Summary Get test schedule +// @Description Retrieves a specific test schedule by ID +// @Tags test-schedules +// @Produce json +// @Param schedule_id path string true "Schedule ID" +// @Success 200 {object} happydns.CheckerSchedule +// @Failure 404 {object} happydns.ErrorResponse +// @Failure 500 {object} happydns.ErrorResponse +// @Router /plugins/tests/schedules/{schedule_id} [get] +func (tc *CheckerScheduleController) GetCheckerSchedule(c *gin.Context) { + user := middleware.MyUser(c) + scheduleIdStr := c.Param("schedule_id") + + scheduleId, err := happydns.NewIdentifierFromString(scheduleIdStr) + if err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid schedule ID")) + return + } + + // Verify ownership + if err := tc.testScheduleUC.ValidateScheduleOwnership(scheduleId, user.Id); err != nil { + middleware.ErrorResponse(c, http.StatusForbidden, err) + return + } + + schedule, err := tc.testScheduleUC.GetSchedule(scheduleId) + if err != nil { + middleware.ErrorResponse(c, http.StatusNotFound, err) + return + } + + c.JSON(http.StatusOK, schedule) +} + +// UpdateCheckerSchedule updates an existing schedule +// +// @Summary Update test schedule +// @Description Updates an existing test schedule +// @Tags test-schedules +// @Accept json +// @Produce json +// @Param schedule_id path string true "Schedule ID" +// @Param body body happydns.CheckerSchedule true "Updated schedule" +// @Success 200 {object} happydns.CheckerSchedule +// @Failure 400 {object} happydns.ErrorResponse +// @Failure 404 {object} happydns.ErrorResponse +// @Failure 500 {object} happydns.ErrorResponse +// @Router /plugins/tests/schedules/{schedule_id} [put] +func (tc *CheckerScheduleController) UpdateCheckerSchedule(c *gin.Context) { + user := middleware.MyUser(c) + scheduleIdStr := c.Param("schedule_id") + + scheduleId, err := happydns.NewIdentifierFromString(scheduleIdStr) + if err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid schedule ID")) + return + } + + // Verify ownership + if err := tc.testScheduleUC.ValidateScheduleOwnership(scheduleId, user.Id); err != nil { + middleware.ErrorResponse(c, http.StatusForbidden, err) + return + } + + var schedule happydns.CheckerSchedule + if err := c.ShouldBindJSON(&schedule); err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err) + return + } + + // Ensure ID matches + schedule.Id = scheduleId + schedule.OwnerId = user.Id + + if err := tc.testScheduleUC.UpdateSchedule(&schedule); err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + + c.JSON(http.StatusOK, schedule) +} + +// DeleteCheckerSchedule deletes a schedule +// +// @Summary Delete test schedule +// @Description Deletes a test schedule +// @Tags test-schedules +// @Produce json +// @Param schedule_id path string true "Schedule ID" +// @Success 204 "No Content" +// @Failure 404 {object} happydns.ErrorResponse +// @Failure 500 {object} happydns.ErrorResponse +// @Router /plugins/tests/schedules/{schedule_id} [delete] +func (tc *CheckerScheduleController) DeleteCheckerSchedule(c *gin.Context) { + user := middleware.MyUser(c) + scheduleIdStr := c.Param("schedule_id") + + scheduleId, err := happydns.NewIdentifierFromString(scheduleIdStr) + if err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid schedule ID")) + return + } + + // Verify ownership + if err := tc.testScheduleUC.ValidateScheduleOwnership(scheduleId, user.Id); err != nil { + middleware.ErrorResponse(c, http.StatusForbidden, err) + return + } + + if err := tc.testScheduleUC.DeleteSchedule(scheduleId); err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/internal/api/route/check.go b/internal/api/route/check.go new file mode 100644 index 00000000..53ee4554 --- /dev/null +++ b/internal/api/route/check.go @@ -0,0 +1,49 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package route + +import ( + "github.com/gin-gonic/gin" + + "git.happydns.org/happyDomain/internal/api/controller" + happydns "git.happydns.org/happyDomain/model" +) + +func DeclareChecksRoutes(router *gin.RouterGroup, checkerUC happydns.CheckerUsecase) { + tpc := controller.NewCheckerController(checkerUC) + + router.GET("/checks", tpc.ListCheckers) + + apiCheckRoutes := router.Group("/checks/:cid") + apiCheckRoutes.Use(tpc.CheckerHandler) + + apiCheckRoutes.GET("", tpc.GetCheckerStatus) + + apiCheckRoutes.GET("/options", tpc.GetCheckerOptions) + apiCheckRoutes.POST("/options", tpc.AddCheckerOptions) + apiCheckRoutes.PUT("/options", tpc.ChangeCheckerOptions) + + apiCheckOptionsRoutes := apiCheckRoutes.Group("/options/:optname") + apiCheckOptionsRoutes.Use(tpc.CheckerOptionHandler) + apiCheckOptionsRoutes.GET("", tpc.GetCheckerOption) + apiCheckOptionsRoutes.PUT("", tpc.SetCheckerOption) +} diff --git a/internal/api/route/checkresults.go b/internal/api/route/checkresults.go new file mode 100644 index 00000000..227e1556 --- /dev/null +++ b/internal/api/route/checkresults.go @@ -0,0 +1,81 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package route + +import ( + "github.com/gin-gonic/gin" + + "git.happydns.org/happyDomain/internal/api/controller" + "git.happydns.org/happyDomain/model" +) + +// DeclareScopedCheckResultRoutes declares test result routes for a specific scope (domain, zone, or service) +func DeclareScopedCheckResultRoutes( + scopedRouter *gin.RouterGroup, + checkerUC happydns.CheckerUsecase, + checkResultUC happydns.CheckResultUsecase, + checkerScheduleUC happydns.CheckerScheduleUsecase, + checkScheduler happydns.SchedulerUsecase, + scope happydns.CheckScopeType, +) { + tc := controller.NewCheckResultController( + scope, + checkerUC, + checkResultUC, + checkerScheduleUC, + checkScheduler, + ) + + // List all available tests with their status + scopedRouter.GET("/tests", tc.ListAvailableChecks) + + // Check-specific routes + apiChecksRoutes := scopedRouter.Group("/tests/:tname") + { + // Get latest results for a test + apiChecksRoutes.GET("", tc.ListLatestCheckResults) + + // Trigger an on-demand test + apiChecksRoutes.POST("", tc.TriggerCheck) + + // Manage check options at this scope + apiChecksRoutes.GET("/options", tc.GetCheckerOptions) + apiChecksRoutes.POST("/options", tc.AddCheckerOptions) + apiChecksRoutes.PUT("/options", tc.ChangeCheckerOptions) + + // Check execution routes + apiCheckExecutionsRoutes := apiChecksRoutes.Group("/executions/:execution_id") + { + apiCheckExecutionsRoutes.GET("", tc.GetCheckExecutionStatus) + } + + // Check results routes + apiChecksRoutes.GET("/results", tc.ListCheckResults) + apiChecksRoutes.DELETE("/results", tc.DropCheckResults) + + apiCheckResultsRoutes := apiChecksRoutes.Group("/results/:result_id") + { + apiCheckResultsRoutes.GET("", tc.GetCheckResult) + apiCheckResultsRoutes.DELETE("", tc.DropCheckResult) + } + } +} diff --git a/internal/api/route/checkschedule.go b/internal/api/route/checkschedule.go new file mode 100644 index 00000000..d6b64a3d --- /dev/null +++ b/internal/api/route/checkschedule.go @@ -0,0 +1,47 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package route + +import ( + "github.com/gin-gonic/gin" + + "git.happydns.org/happyDomain/internal/api/controller" + "git.happydns.org/happyDomain/model" +) + +// DeclareTestScheduleRoutes declares test schedule management routes +func DeclareTestScheduleRoutes(router *gin.RouterGroup, checkerScheduleUC happydns.CheckerScheduleUsecase) { + sc := controller.NewCheckerScheduleController(checkerScheduleUC) + + schedulesRoutes := router.Group("/plugins/tests/schedules") + { + schedulesRoutes.GET("", sc.ListCheckerSchedules) + schedulesRoutes.POST("", sc.CreateCheckerSchedule) + + scheduleRoutes := schedulesRoutes.Group("/:schedule_id") + { + scheduleRoutes.GET("", sc.GetCheckerSchedule) + scheduleRoutes.PUT("", sc.UpdateCheckerSchedule) + scheduleRoutes.DELETE("", sc.DeleteCheckerSchedule) + } + } +} diff --git a/internal/api/route/domain.go b/internal/api/route/domain.go index 491a65a0..af302eb4 100644 --- a/internal/api/route/domain.go +++ b/internal/api/route/domain.go @@ -29,7 +29,7 @@ import ( "git.happydns.org/happyDomain/model" ) -func DeclareDomainRoutes(router *gin.RouterGroup, domainUC happydns.DomainUsecase, domainLogUC happydns.DomainLogUsecase, remoteZoneImporter happydns.RemoteZoneImporterUsecase, zoneImporter happydns.ZoneImporterUsecase, zoneUC happydns.ZoneUsecase, zoneCorrApplier happydns.ZoneCorrectionApplierUsecase, zoneServiceUC happydns.ZoneServiceUsecase, serviceUC happydns.ServiceUsecase) { +func DeclareDomainRoutes(router *gin.RouterGroup, domainUC happydns.DomainUsecase, domainLogUC happydns.DomainLogUsecase, remoteZoneImporter happydns.RemoteZoneImporterUsecase, zoneImporter happydns.ZoneImporterUsecase, zoneUC happydns.ZoneUsecase, zoneCorrApplier happydns.ZoneCorrectionApplierUsecase, zoneServiceUC happydns.ZoneServiceUsecase, serviceUC happydns.ServiceUsecase, checkerUC happydns.CheckerUsecase, checkResultUC happydns.CheckResultUsecase, checkerScheduleUC happydns.CheckerScheduleUsecase, checkScheduler happydns.SchedulerUsecase) { dc := controller.NewDomainController( domainUC, remoteZoneImporter, @@ -48,8 +48,12 @@ func DeclareDomainRoutes(router *gin.RouterGroup, domainUC happydns.DomainUsecas DeclareDomainLogRoutes(apiDomainsRoutes, domainLogUC) + // Declare test result routes for domain scope + + DeclareScopedCheckResultRoutes(apiDomainsRoutes, checkerUC, checkResultUC, checkerScheduleUC, checkScheduler, happydns.CheckScopeDomain) + apiDomainsRoutes.POST("/zone", dc.ImportZone) apiDomainsRoutes.POST("/retrieve_zone", dc.RetrieveZone) - DeclareZoneRoutes(apiDomainsRoutes, zoneUC, domainUC, zoneCorrApplier, zoneServiceUC, serviceUC) + DeclareZoneRoutes(apiDomainsRoutes, zoneUC, domainUC, zoneCorrApplier, zoneServiceUC, serviceUC, checkerUC, checkResultUC, checkerScheduleUC, checkScheduler) } diff --git a/internal/api/route/route.go b/internal/api/route/route.go index 759a84ff..61160eb2 100644 --- a/internal/api/route/route.go +++ b/internal/api/route/route.go @@ -34,6 +34,10 @@ type Dependencies struct { Authentication happydns.AuthenticationUsecase AuthUser happydns.AuthUserUsecase CaptchaVerifier happydns.CaptchaVerifier + Checker happydns.CheckerUsecase + CheckResult happydns.CheckResultUsecase + CheckerSchedule happydns.CheckerScheduleUsecase + CheckScheduler happydns.SchedulerUsecase Domain happydns.DomainUsecase DomainLog happydns.DomainLogUsecase FailureTracker happydns.FailureTracker @@ -99,10 +103,12 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, dep Dependenc apiAuthRoutes.Use(middleware.AuthRequired()) DeclareAuthenticationCheckRoutes(apiAuthRoutes, lc) - DeclareDomainRoutes(apiAuthRoutes, dep.Domain, dep.DomainLog, dep.RemoteZoneImporter, dep.ZoneImporter, dep.Zone, dep.ZoneCorrectionApplier, dep.ZoneService, dep.Service) + DeclareChecksRoutes(apiAuthRoutes, dep.Checker) + DeclareDomainRoutes(apiAuthRoutes, dep.Domain, dep.DomainLog, dep.RemoteZoneImporter, dep.ZoneImporter, dep.Zone, dep.ZoneCorrectionApplier, dep.ZoneService, dep.Service, dep.Checker, dep.CheckResult, dep.CheckerSchedule, dep.CheckScheduler) DeclareProviderRoutes(apiAuthRoutes, dep.Provider) DeclareProviderSettingsRoutes(apiAuthRoutes, dep.ProviderSettings) DeclareRecordRoutes(apiAuthRoutes) + DeclareTestScheduleRoutes(apiAuthRoutes, dep.CheckerSchedule) DeclareUsersRoutes(apiAuthRoutes, dep.User, lc) DeclareSessionRoutes(apiAuthRoutes, dep.Session) } diff --git a/internal/api/route/service.go b/internal/api/route/service.go index de2d5fd9..fbb07525 100644 --- a/internal/api/route/service.go +++ b/internal/api/route/service.go @@ -29,7 +29,7 @@ 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, checkerUC happydns.CheckerUsecase, checkResultUC happydns.CheckResultUsecase, checkerScheduleUC happydns.CheckerScheduleUsecase, checkScheduler happydns.SchedulerUsecase) { sc := controller.NewServiceController(zoneServiceUC, serviceUC, zoneUC) apiZonesRoutes.PATCH("", sc.UpdateZoneService) @@ -40,4 +40,7 @@ func DeclareZoneServiceRoutes(apiZonesRoutes, apiZonesSubdomainRoutes *gin.Route apiZonesSubdomainServiceIdRoutes.Use(middleware.ServiceIdHandler(serviceUC)) apiZonesSubdomainServiceIdRoutes.GET("", sc.GetZoneService) apiZonesSubdomainServiceIdRoutes.DELETE("", sc.DeleteZoneService) + + // Declare test result routes for service scope + DeclareScopedCheckResultRoutes(apiZonesSubdomainServiceIdRoutes, checkerUC, checkResultUC, checkerScheduleUC, checkScheduler, happydns.CheckScopeService) } diff --git a/internal/api/route/zone.go b/internal/api/route/zone.go index fb39d7a2..e7ddba8b 100644 --- a/internal/api/route/zone.go +++ b/internal/api/route/zone.go @@ -29,7 +29,7 @@ import ( happydns "git.happydns.org/happyDomain/model" ) -func DeclareZoneRoutes(router *gin.RouterGroup, zoneUC happydns.ZoneUsecase, domainUC happydns.DomainUsecase, zoneCorrApplier happydns.ZoneCorrectionApplierUsecase, zoneServiceUC happydns.ZoneServiceUsecase, serviceUC happydns.ServiceUsecase) { +func DeclareZoneRoutes(router *gin.RouterGroup, zoneUC happydns.ZoneUsecase, domainUC happydns.DomainUsecase, zoneCorrApplier happydns.ZoneCorrectionApplierUsecase, zoneServiceUC happydns.ZoneServiceUsecase, serviceUC happydns.ServiceUsecase, checkerUC happydns.CheckerUsecase, checkResultUC happydns.CheckResultUsecase, checkerScheduleUC happydns.CheckerScheduleUsecase, checkScheduler happydns.SchedulerUsecase) { zc := controller.NewZoneController( zoneUC, domainUC, @@ -50,7 +50,7 @@ func DeclareZoneRoutes(router *gin.RouterGroup, zoneUC happydns.ZoneUsecase, dom apiZonesSubdomainRoutes.Use(middleware.SubdomainHandler) apiZonesSubdomainRoutes.GET("", zc.GetZoneSubdomain) - DeclareZoneServiceRoutes(apiZonesRoutes, apiZonesSubdomainRoutes, zc, zoneServiceUC, serviceUC, zoneUC) + DeclareZoneServiceRoutes(apiZonesRoutes, apiZonesSubdomainRoutes, zc, zoneServiceUC, serviceUC, zoneUC, checkerUC, checkResultUC, checkerScheduleUC, checkScheduler) apiZonesRoutes.POST("/records", zc.AddRecords) apiZonesRoutes.POST("/records/delete", zc.DeleteRecords) diff --git a/internal/app/admin.go b/internal/app/admin.go index 6b06af35..18e00fa4 100644 --- a/internal/app/admin.go +++ b/internal/app/admin.go @@ -58,6 +58,8 @@ func NewAdmin(app *App) *Admin { admin.DeclareRoutes(app.cfg, router, app.store, admin.Dependencies{ AuthUser: app.usecases.authUser, + Checker: app.usecases.checker, + CheckScheduler: app.checkScheduler, Domain: app.usecases.domain, Provider: app.usecases.providerAdmin, RemoteZoneImporter: app.usecases.orchestrator.RemoteZoneImporter, diff --git a/internal/app/app.go b/internal/app/app.go index 137236c0..4b8aaf18 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -38,6 +38,8 @@ import ( "git.happydns.org/happyDomain/internal/storage" "git.happydns.org/happyDomain/internal/usecase" authuserUC "git.happydns.org/happyDomain/internal/usecase/authuser" + checkUC "git.happydns.org/happyDomain/internal/usecase/check" + checkresultUC "git.happydns.org/happyDomain/internal/usecase/checkresult" domainUC "git.happydns.org/happyDomain/internal/usecase/domain" domainlogUC "git.happydns.org/happyDomain/internal/usecase/domain_log" "git.happydns.org/happyDomain/internal/usecase/orchestrator" @@ -54,6 +56,9 @@ import ( type Usecases struct { authentication happydns.AuthenticationUsecase authUser happydns.AuthUserUsecase + checker happydns.CheckerUsecase + checkResult happydns.CheckResultUsecase + checkerSchedule happydns.CheckerScheduleUsecase domain happydns.DomainUsecase domainLog happydns.DomainLogUsecase provider happydns.ProviderUsecase @@ -81,10 +86,10 @@ type App struct { router *gin.Engine srv *http.Server store storage.Storage + checkScheduler happydns.SchedulerUsecase usecases Usecases } - func NewApp(cfg *happydns.Options) *App { app := &App{ cfg: cfg, @@ -94,8 +99,12 @@ func NewApp(cfg *happydns.Options) *App { app.initStorageEngine() app.initNewsletter() app.initInsights() + if err := app.initPlugins(); err != nil { + log.Fatalf("Plugin initialization error: %s", err) + } app.initUsecases() app.initCaptcha() + app.initCheckScheduler() app.setupRouter() return app @@ -109,8 +118,12 @@ func NewAppWithStorage(cfg *happydns.Options, store storage.Storage) *App { app.initMailer() app.initNewsletter() + if err := app.initPlugins(); err != nil { + log.Fatalf("Plugin initialization error: %s", err) + } app.initUsecases() app.initCaptcha() + app.initCheckScheduler() app.setupRouter() return app @@ -184,6 +197,20 @@ func (app *App) initInsights() { } } +func (app *App) initCheckScheduler() { + if app.cfg.DisableScheduler { + // Use a disabled scheduler that returns clear errors + app.checkScheduler = &disabledScheduler{} + return + } + + app.checkScheduler = newCheckScheduler( + app.cfg, + app.store, + app.usecases.checker, + ) +} + func (app *App) initUsecases() { sessionService := sessionUC.NewService(app.store) authUserService := authuserUC.NewAuthUserUsecases(app.cfg, app.mailer, app.store, sessionService) @@ -211,6 +238,9 @@ func (app *App) initUsecases() { app.usecases.authUser = authUserService app.usecases.resolver = usecase.NewResolverUsecase(app.cfg) app.usecases.session = sessionService + app.usecases.checker = checkUC.NewCheckerUsecase(app.cfg, app.store, app.store) + app.usecases.checkResult = checkresultUC.NewCheckResultUsecase(app.store, app.cfg) + app.usecases.checkerSchedule = checkresultUC.NewCheckScheduleUsecase(app.store, app.cfg, app.store, app.usecases.checker) app.usecases.orchestrator = orchestrator.NewOrchestrator( domainLogService, @@ -248,6 +278,7 @@ func (app *App) setupRouter() { Authentication: app.usecases.authentication, AuthUser: app.usecases.authUser, CaptchaVerifier: app.captchaVerifier, + Checker: app.usecases.checker, Domain: app.usecases.domain, DomainLog: app.usecases.domainLog, FailureTracker: app.failureTracker, @@ -280,6 +311,8 @@ func (app *App) Start() { go app.insights.Run() } + go app.checkScheduler.Run() + log.Printf("Public interface listening on %s\n", app.cfg.Bind) if err := app.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("listen: %s\n", err) @@ -305,4 +338,6 @@ func (app *App) Stop() { if app.failureTracker != nil { app.failureTracker.Close() } + + app.checkScheduler.Close() } diff --git a/internal/app/checkscheduler.go b/internal/app/checkscheduler.go new file mode 100644 index 00000000..d9d9850f --- /dev/null +++ b/internal/app/checkscheduler.go @@ -0,0 +1,668 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package app + +import ( + "container/heap" + "context" + "fmt" + "log" + "runtime" + "sync" + "time" + + "git.happydns.org/happyDomain/internal/storage" + "git.happydns.org/happyDomain/internal/usecase/checkresult" + "git.happydns.org/happyDomain/model" +) + +const ( + SchedulerCheckInterval = 1 * time.Minute // How often to check for due tests + SchedulerCleanupInterval = 24 * time.Hour // How often to clean up old executions + SchedulerDiscoveryInterval = 1 * time.Hour // How often to auto-discover new targets + CheckExecutionTimeout = 5 * time.Minute // Max time for a single check + MaxRetries = 3 // Max retry attempts for failed checks +) + +// Priority levels for test execution queue +const ( + PriorityOnDemand = iota // On-demand tests (highest priority) + PriorityOverdue // Overdue scheduled tests + PriorityScheduled // Regular scheduled tests +) + +// checkScheduler manages background test execution +type checkScheduler struct { + cfg *happydns.Options + store storage.Storage + checkerUsecase happydns.CheckerUsecase + resultUsecase *checkresult.CheckResultUsecase + scheduleUsecase *checkresult.CheckScheduleUsecase + stop chan struct{} // closed to stop the main Run loop + stopWorkers chan struct{} // closed to stop all workers simultaneously + runNowChan chan *queueItem // on-demand items routed through the main loop + workAvail chan struct{} // non-blocking signals that queue has new work + queue *priorityQueue + activeExecutions map[string]*activeExecution + workers []*worker + mu sync.RWMutex + wg sync.WaitGroup + runtimeEnabled bool + running bool +} + +// activeExecution tracks a running test execution +type activeExecution struct { + execution *happydns.CheckExecution + cancel context.CancelFunc + startTime time.Time +} + +// queueItem represents a test execution request in the queue +type queueItem struct { + schedule *happydns.CheckerSchedule + execution *happydns.CheckExecution + priority int + queuedAt time.Time + retries int +} + +// --- container/heap implementation for priorityQueue --- + +// priorityHeap is the underlying heap, ordered by priority then arrival time. +type priorityHeap []*queueItem + +func (h priorityHeap) Len() int { return len(h) } +func (h priorityHeap) Less(i, j int) bool { + if h[i].priority != h[j].priority { + return h[i].priority < h[j].priority + } + return h[i].queuedAt.Before(h[j].queuedAt) +} +func (h priorityHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] } +func (h *priorityHeap) Push(x any) { *h = append(*h, x.(*queueItem)) } +func (h *priorityHeap) Pop() any { + old := *h + n := len(old) + x := old[n-1] + old[n-1] = nil // avoid memory leak + *h = old[:n-1] + return x +} + +// priorityQueue is a thread-safe min-heap of queueItems. +type priorityQueue struct { + h priorityHeap + mu sync.Mutex +} + +func newPriorityQueue() *priorityQueue { + pq := &priorityQueue{} + heap.Init(&pq.h) + return pq +} + +// Push adds an item to the queue. +func (q *priorityQueue) Push(item *queueItem) { + q.mu.Lock() + defer q.mu.Unlock() + heap.Push(&q.h, item) +} + +// Pop removes and returns the highest-priority item, or nil if empty. +func (q *priorityQueue) Pop() *queueItem { + q.mu.Lock() + defer q.mu.Unlock() + if q.h.Len() == 0 { + return nil + } + return heap.Pop(&q.h).(*queueItem) +} + +// Len returns the queue length. +func (q *priorityQueue) Len() int { + q.mu.Lock() + defer q.mu.Unlock() + return q.h.Len() +} + +// worker processes tests from the queue +type worker struct { + id int + scheduler *checkScheduler +} + +// disabledScheduler is a no-op implementation used when scheduler is disabled +type disabledScheduler struct{} + +func (d *disabledScheduler) Run() {} +func (d *disabledScheduler) Close() {} + +// TriggerOnDemandCheck returns an error indicating the scheduler is disabled +func (d *disabledScheduler) TriggerOnDemandCheck(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, userId happydns.Identifier, options happydns.CheckerOptions) (happydns.Identifier, error) { + return happydns.Identifier{}, fmt.Errorf("test scheduler is disabled in configuration") +} + +// GetSchedulerStatus returns a status indicating the scheduler is disabled +func (d *disabledScheduler) GetSchedulerStatus() happydns.SchedulerStatus { + return happydns.SchedulerStatus{ + ConfigEnabled: false, + RuntimeEnabled: false, + Running: false, + } +} + +// SetEnabled returns an error since the scheduler is disabled in configuration +func (d *disabledScheduler) SetEnabled(enabled bool) error { + return fmt.Errorf("scheduler is disabled in configuration, cannot enable at runtime") +} + +// RescheduleUpcomingChecks returns an error since the scheduler is disabled +func (d *disabledScheduler) RescheduleUpcomingChecks() (int, error) { + return 0, fmt.Errorf("test scheduler is disabled in configuration") +} + +// newCheckScheduler creates a new test scheduler +func newCheckScheduler( + cfg *happydns.Options, + store storage.Storage, + checkerUsecase happydns.CheckerUsecase, +) *checkScheduler { + numWorkers := cfg.TestWorkers + if numWorkers <= 0 { + numWorkers = runtime.NumCPU() + } + + scheduler := &checkScheduler{ + cfg: cfg, + store: store, + checkerUsecase: checkerUsecase, + resultUsecase: checkresult.NewCheckResultUsecase(store, cfg), + scheduleUsecase: checkresult.NewCheckScheduleUsecase(store, cfg, store, checkerUsecase), + stop: make(chan struct{}), + stopWorkers: make(chan struct{}), + runNowChan: make(chan *queueItem, 100), + workAvail: make(chan struct{}, numWorkers), + queue: newPriorityQueue(), + activeExecutions: make(map[string]*activeExecution), + workers: make([]*worker, numWorkers), + runtimeEnabled: true, + } + + for i := 0; i < numWorkers; i++ { + scheduler.workers[i] = &worker{ + id: i, + scheduler: scheduler, + } + } + + return scheduler +} + +// enqueue pushes an item to the priority queue and wakes one idle worker. +func (s *checkScheduler) enqueue(item *queueItem) { + s.queue.Push(item) + select { + case s.workAvail <- struct{}{}: + default: + // All workers are already busy or already notified; they will drain + // the queue on their own after finishing the current item. + } +} + +// Close stops the scheduler and waits for all workers to finish. +func (s *checkScheduler) Close() { + log.Println("Stopping test scheduler...") + + // Unblock the main Run loop. + close(s.stop) + + // Unblock all workers simultaneously. + close(s.stopWorkers) + + // Cancel all active test executions. + s.mu.Lock() + for _, exec := range s.activeExecutions { + exec.cancel() + } + s.mu.Unlock() + + // Wait for all workers to finish their current item. + s.wg.Wait() + + log.Println("Check scheduler stopped") +} + +// Run starts the scheduler main loop. It must not be called more than once. +func (s *checkScheduler) Run() { + s.mu.Lock() + s.running = true + s.mu.Unlock() + + defer func() { + s.mu.Lock() + s.running = false + s.mu.Unlock() + }() + + log.Printf("Starting test scheduler with %d workers...\n", len(s.workers)) + + // Reschedule overdue tests before starting workers so that tests missed + // during a server suspend or shutdown are spread into the near future + // instead of all firing at once. + if n, err := s.scheduleUsecase.RescheduleOverdueChecks(); err != nil { + log.Printf("Warning: failed to reschedule overdue tests: %v\n", err) + } else if n > 0 { + log.Printf("Rescheduled %d overdue test(s) into the near future\n", n) + } + + // Start workers + for _, w := range s.workers { + s.wg.Add(1) + go w.run(&s.wg) + } + + // Main scheduling loop + checkTicker := time.NewTicker(SchedulerCheckInterval) + cleanupTicker := time.NewTicker(SchedulerCleanupInterval) + discoveryTicker := time.NewTicker(SchedulerDiscoveryInterval) + defer checkTicker.Stop() + defer cleanupTicker.Stop() + defer discoveryTicker.Stop() + + // Initial discovery: create default schedules for all existing targets + if err := s.scheduleUsecase.DiscoverAndEnsureSchedules(); err != nil { + log.Printf("Warning: schedule discovery encountered errors: %v\n", err) + } + // Initial check + s.checkSchedules() + + for { + select { + case <-checkTicker.C: + s.checkSchedules() + + case <-cleanupTicker.C: + s.cleanup() + + case <-discoveryTicker.C: + if err := s.scheduleUsecase.DiscoverAndEnsureSchedules(); err != nil { + log.Printf("Warning: schedule discovery encountered errors: %v\n", err) + } + + case item := <-s.runNowChan: + s.enqueue(item) + + case <-s.stop: + return + } + } +} + +// checkSchedules checks for due tests and queues them +func (s *checkScheduler) checkSchedules() { + s.mu.RLock() + enabled := s.runtimeEnabled + s.mu.RUnlock() + if !enabled { + return + } + + dueSchedules, err := s.scheduleUsecase.ListDueSchedules() + if err != nil { + log.Printf("Error listing due schedules: %v\n", err) + return + } + + now := time.Now() + for _, schedule := range dueSchedules { + // Determine priority based on how overdue the test is + priority := PriorityScheduled + if schedule.NextRun.Add(schedule.Interval).Before(now) { + priority = PriorityOverdue + } + + // Create execution record + execution := &happydns.CheckExecution{ + ScheduleId: &schedule.Id, + CheckerName: schedule.CheckerName, + OwnerId: schedule.OwnerId, + TargetType: schedule.TargetType, + TargetId: schedule.TargetId, + Status: happydns.CheckExecutionPending, + StartedAt: time.Now(), + Options: schedule.Options, + } + + if err := s.resultUsecase.CreateCheckExecution(execution); err != nil { + log.Printf("Error creating execution for schedule %s: %v\n", schedule.Id.String(), err) + continue + } + + s.enqueue(&queueItem{ + schedule: schedule, + execution: execution, + priority: priority, + queuedAt: now, + retries: 0, + }) + } + + // Mark scheduler run + if err := s.store.CheckSchedulerRun(); err != nil { + log.Printf("Error marking scheduler run: %v\n", err) + } +} + +// TriggerOnDemandCheck triggers an immediate test execution. +// It creates the execution record synchronously (so the caller gets an ID back) +// and then routes the item through runNowChan so the main loop controls +// all queue insertions. +func (s *checkScheduler) TriggerOnDemandCheck(checkerName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, ownerId happydns.Identifier, options happydns.CheckerOptions) (happydns.Identifier, error) { + schedule := &happydns.CheckerSchedule{ + CheckerName: checkerName, + OwnerId: ownerId, + TargetType: targetType, + TargetId: targetId, + Interval: 0, // On-demand, no interval + Enabled: true, + Options: options, + } + + execution := &happydns.CheckExecution{ + ScheduleId: nil, + CheckerName: checkerName, + OwnerId: ownerId, + TargetType: targetType, + TargetId: targetId, + Status: happydns.CheckExecutionPending, + StartedAt: time.Now(), + Options: options, + } + + if err := s.resultUsecase.CreateCheckExecution(execution); err != nil { + return happydns.Identifier{}, err + } + + item := &queueItem{ + schedule: schedule, + execution: execution, + priority: PriorityOnDemand, + queuedAt: time.Now(), + retries: 0, + } + + // Route through the main loop when possible; fall back to direct enqueue + // if the channel is full so that the caller never blocks. + select { + case s.runNowChan <- item: + default: + s.enqueue(item) + } + + return execution.Id, nil +} + +// GetSchedulerStatus returns a snapshot of the current scheduler state +func (s *checkScheduler) GetSchedulerStatus() happydns.SchedulerStatus { + s.mu.RLock() + activeCount := len(s.activeExecutions) + running := s.running + runtimeEnabled := s.runtimeEnabled + s.mu.RUnlock() + + nextSchedules, _ := s.scheduleUsecase.ListUpcomingSchedules(20) + + return happydns.SchedulerStatus{ + ConfigEnabled: !s.cfg.DisableScheduler, + RuntimeEnabled: runtimeEnabled, + Running: running, + WorkerCount: len(s.workers), + QueueSize: s.queue.Len(), + ActiveCount: activeCount, + NextSchedules: nextSchedules, + } +} + +// SetEnabled enables or disables the scheduler at runtime +func (s *checkScheduler) SetEnabled(enabled bool) error { + s.mu.Lock() + wasEnabled := s.runtimeEnabled + s.runtimeEnabled = enabled + s.mu.Unlock() + + if enabled && !wasEnabled { + // Spread out any overdue tests to avoid a thundering herd, then + // immediately enqueue whatever is now due. + if n, err := s.scheduleUsecase.RescheduleOverdueChecks(); err != nil { + log.Printf("Warning: failed to reschedule overdue tests on re-enable: %v\n", err) + } else if n > 0 { + log.Printf("Rescheduled %d overdue test(s) after scheduler re-enable\n", n) + } + s.checkSchedules() + } + + return nil +} + +// RescheduleUpcomingChecks randomizes the next run time of all enabled schedules +// within their respective intervals, delegating to the schedule usecase. +func (s *checkScheduler) RescheduleUpcomingChecks() (int, error) { + return s.scheduleUsecase.RescheduleUpcomingChecks() +} + +// cleanup removes old execution records and expired test results +func (s *checkScheduler) cleanup() { + log.Println("Running scheduler cleanup...") + + // Delete completed/failed execution records older than 7 days + if err := s.resultUsecase.DeleteCompletedExecutions(7 * 24 * time.Hour); err != nil { + log.Printf("Error cleaning up old executions: %v\n", err) + } + + // Delete test results older than the configured retention period + if err := s.resultUsecase.CleanupOldResults(); err != nil { + log.Printf("Error cleaning up old test results: %v\n", err) + } + + log.Println("Scheduler cleanup complete") +} + +// run is the worker's main loop. It drains the queue eagerly and waits for a +// workAvail signal when idle, rather than sleeping on a fixed timer. +func (w *worker) run(wg *sync.WaitGroup) { + defer wg.Done() + + log.Printf("Worker %d started\n", w.id) + + for { + // Drain: try to grab work before blocking. + if item := w.scheduler.queue.Pop(); item != nil { + w.executeCheck(item) + continue + } + + // Queue is empty; wait for new work or a stop signal. + select { + case <-w.scheduler.workAvail: + // Loop back to attempt a Pop. + case <-w.scheduler.stopWorkers: + log.Printf("Worker %d stopped\n", w.id) + return + } + } +} + +// executeCheck runs a checker and stores the result +func (w *worker) executeCheck(item *queueItem) { + ctx, cancel := context.WithTimeout(context.Background(), CheckExecutionTimeout) + defer cancel() + + execution := item.execution + schedule := item.schedule + + // Always update schedule NextRun after execution, whether it succeeds or fails. + // This prevents the schedule from being re-queued on the next tick if the test fails. + if item.execution.ScheduleId != nil { + defer func() { + if err := w.scheduler.scheduleUsecase.UpdateScheduleAfterRun(*item.execution.ScheduleId); err != nil { + log.Printf("Worker %d: Error updating schedule after run: %v\n", w.id, err) + } + }() + } + + // Mark execution as running + execution.Status = happydns.CheckExecutionRunning + if err := w.scheduler.resultUsecase.UpdateCheckExecution(execution); err != nil { + log.Printf("Worker %d: Error updating execution status: %v\n", w.id, err) + _ = w.scheduler.resultUsecase.FailCheckExecution(execution.Id, err.Error()) + return + } + + // Track active execution + w.scheduler.mu.Lock() + w.scheduler.activeExecutions[execution.Id.String()] = &activeExecution{ + execution: execution, + cancel: cancel, + startTime: time.Now(), + } + w.scheduler.mu.Unlock() + + defer func() { + w.scheduler.mu.Lock() + delete(w.scheduler.activeExecutions, execution.Id.String()) + w.scheduler.mu.Unlock() + }() + + // Get the checker + checker, err := w.scheduler.checkerUsecase.GetChecker(schedule.CheckerName) + if err != nil { + errMsg := fmt.Sprintf("checker not found: %s - %v", schedule.CheckerName, err) + log.Printf("Worker %d: %s\n", w.id, errMsg) + _ = w.scheduler.resultUsecase.FailCheckExecution(execution.Id, errMsg) + return + } + + // For scheduled tests: merge checker defaults < stored (user/domain/service) opts < schedule opts < auto-fill. + // For on-demand tests the caller has already merged all options, so use them directly. + var mergedOptions happydns.CheckerOptions + if item.execution.ScheduleId != nil { + var domainId, serviceId *happydns.Identifier + switch schedule.TargetType { + case happydns.CheckScopeDomain: + domainId = &schedule.TargetId + case happydns.CheckScopeService: + serviceId = &schedule.TargetId + } + var mergeErr error + mergedOptions, mergeErr = w.scheduler.checkerUsecase.BuildMergedCheckerOptions(schedule.CheckerName, &schedule.OwnerId, domainId, serviceId, schedule.Options) + if mergeErr != nil { + // Non-fatal: fall back to schedule-only options + log.Printf("Worker %d: warning, could not prepare checker options for %s: %v\n", w.id, schedule.CheckerName, mergeErr) + mergedOptions = schedule.Options + } + } else { + mergedOptions = schedule.Options + } + + // Prepare metadata + meta := make(map[string]string) + meta["target_type"] = schedule.TargetType.String() + meta["target_id"] = schedule.TargetId.String() + + // Run the test + startTime := time.Now() + resultChan := make(chan *happydns.CheckResult, 1) + errorChan := make(chan error, 1) + + go func() { + defer func() { + if r := recover(); r != nil { + errorChan <- fmt.Errorf("checker panicked: %v", r) + } + }() + result, err := checker.RunCheck(mergedOptions, meta) + if err != nil { + errorChan <- err + } else { + resultChan <- result + } + }() + + // Wait for result or timeout + var checkResult *happydns.CheckResult + var testErr error + + select { + case checkResult = <-resultChan: + // Check completed successfully + case testErr = <-errorChan: + // Check returned an error + case <-ctx.Done(): + // Timeout + testErr = fmt.Errorf("test execution timeout after %v", CheckExecutionTimeout) + } + + duration := time.Since(startTime) + + // Store the result + result := &happydns.CheckResult{ + CheckerName: schedule.CheckerName, + CheckType: schedule.TargetType, + TargetId: schedule.TargetId, + OwnerId: schedule.OwnerId, + ExecutedAt: time.Now(), + ScheduledCheck: item.execution.ScheduleId != nil, + Options: schedule.Options, + Duration: duration, + } + + if testErr != nil { + result.Status = happydns.CheckResultStatusKO + result.StatusLine = "Check execution failed" + result.Error = testErr.Error() + } else if checkResult != nil { + result.Status = checkResult.Status + result.StatusLine = checkResult.StatusLine + result.Report = checkResult.Report + } else { + result.Status = happydns.CheckResultStatusKO + result.StatusLine = "Unknown error" + result.Error = "No result or error returned from check" + } + + // Save the result + if err := w.scheduler.resultUsecase.CreateCheckResult(result); err != nil { + log.Printf("Worker %d: Error saving test result: %v\n", w.id, err) + _ = w.scheduler.resultUsecase.FailCheckExecution(execution.Id, err.Error()) + return + } + + // Complete the execution + if err := w.scheduler.resultUsecase.CompleteCheckExecution(execution.Id, result.Id); err != nil { + log.Printf("Worker %d: Error completing execution: %v\n", w.id, err) + return + } + + log.Printf("Worker %d: Completed test %s for target %s (status: %d, duration: %v)\n", + w.id, schedule.CheckerName, schedule.TargetId.String(), result.Status, duration) +} diff --git a/internal/app/plugins.go b/internal/app/plugins.go new file mode 100644 index 00000000..1fcc3aea --- /dev/null +++ b/internal/app/plugins.go @@ -0,0 +1,134 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package app + +import ( + "fmt" + "log" + "os" + "path/filepath" + "plugin" + + "git.happydns.org/happyDomain/checks" + "git.happydns.org/happyDomain/model" +) + +// pluginLoader attempts to find and register one specific kind of plugin +// symbol from an already-opened .so file. +// +// It returns (true, nil) when the symbol was found and registration +// succeeded, (true, err) when the symbol was found but something went wrong, +// and (false, nil) when the symbol simply isn't present in that file (which +// is not considered an error — a single .so may implement only a subset of +// the known plugin types). +type pluginLoader func(p *plugin.Plugin, fname string) (found bool, err error) + +// pluginLoaders is the authoritative list of plugin types that happyDomain +// knows about. To support a new plugin type, add a single entry here. +var pluginLoaders = []pluginLoader{ + loadCheckPlugin, +} + +// loadCheckPlugin handles the NewTestPlugin symbol. +func loadCheckPlugin(p *plugin.Plugin, fname string) (bool, error) { + sym, err := p.Lookup("NewCheckPlugin") + if err != nil { + // Symbol not present in this .so — not an error. + return false, nil + } + + factory, ok := sym.(func() (string, happydns.Checker, error)) + if !ok { + return true, fmt.Errorf("symbol NewCheckPlugin has unexpected type %T", sym) + } + + pluginname, myplugin, err := factory() + if err != nil { + return true, err + } + + checks.RegisterChecker(pluginname, myplugin) + log.Printf("Plugin %s loaded", pluginname) + return true, nil +} + +// initPlugins scans each directory listed in cfg.PluginsDirectories, loads +// every .so file found as a Go plugin, and registers it in the application's +// PluginManager. All load errors are collected and returned as a joined error +// so that a single bad plugin does not prevent the others from loading. +func (a *App) initPlugins() error { + for _, directory := range a.cfg.PluginsDirectories { + files, err := os.ReadDir(directory) + if err != nil { + return fmt.Errorf("unable to read plugins directory %q: %s", directory, err) + } + + for _, file := range files { + if file.IsDir() { + continue + } + + // Only attempt to load shared-object files. + if filepath.Ext(file.Name()) != ".so" { + continue + } + + fname := filepath.Join(directory, file.Name()) + + err = loadPlugin(fname) + if err != nil { + log.Printf("Unable to load plugin %q: %s", fname, err) + } + } + } + + return nil +} + +// loadPlugin opens the .so file at fname and runs every registered +// pluginLoader against it. A loader that does not find its symbol is silently +// skipped. If no loader recognises any symbol in the file a warning is logged, +// but no error is returned because the file might be a valid plugin for a +// future version of happyDomain. The first loader error that is encountered +// is returned immediately. +func loadPlugin(fname string) error { + p, err := plugin.Open(fname) + if err != nil { + return err + } + + anyFound := false + for _, loader := range pluginLoaders { + found, err := loader(p, fname) + if err != nil { + return err + } + if found { + anyFound = true + } + } + + if !anyFound { + log.Printf("Warning: plugin %q exports no recognised symbols", fname) + } + return nil +} diff --git a/internal/config/cli.go b/internal/config/cli.go index 3c27aa30..4c3f463c 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -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 } diff --git a/internal/config/config.go b/internal/config/config.go index 023d17fb..4ce56c8e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -44,15 +44,18 @@ func ConsolidateConfig() (opts *happydns.Options, err error) { // Define defaults options opts = &happydns.Options{ - AdminBind: "./happydomain.sock", - BasePath: "/", - Bind: ":8081", - DefaultNameServer: "127.0.0.1:53", - ExternalURL: *u, - JWTSigningMethod: "HS512", - MailFrom: mail.Address{Name: "happyDomain", Address: "happydomain@localhost"}, - MailSMTPPort: 587, - StorageEngine: "leveldb", + AdminBind: "./happydomain.sock", + BasePath: "/", + Bind: ":8081", + DefaultNameServer: "127.0.0.1:53", + ExternalURL: *u, + JWTSigningMethod: "HS512", + MailFrom: mail.Address{Name: "happyDomain", Address: "happydomain@localhost"}, + MailSMTPPort: 587, + StorageEngine: "leveldb", + MaxResultsPerCheck: 100, + ResultRetentionDays: 90, + TestWorkers: 2, } declareFlags(opts) diff --git a/internal/config/custom.go b/internal/config/custom.go index f69cac42..c3e48d80 100644 --- a/internal/config/custom.go +++ b/internal/config/custom.go @@ -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 } diff --git a/internal/storage/inmemory/database.go b/internal/storage/inmemory/database.go index 824d0cc9..04ba9712 100644 --- a/internal/storage/inmemory/database.go +++ b/internal/storage/inmemory/database.go @@ -40,6 +40,7 @@ type InMemoryStorage struct { data map[string][]byte // Generic key-value store for KVStorage interface authUsers map[string]*happydns.UserAuth authUsersByEmail map[string]happydns.Identifier + checksCfg map[string]*happydns.CheckerOptions domains map[string]*happydns.Domain domainLogs map[string]*happydns.DomainLogWithDomainId domainLogsByDomains map[string][]*happydns.Identifier @@ -58,6 +59,7 @@ func NewInMemoryStorage() (*InMemoryStorage, error) { data: make(map[string][]byte), authUsers: make(map[string]*happydns.UserAuth), authUsersByEmail: make(map[string]happydns.Identifier), + checksCfg: make(map[string]*happydns.CheckerOptions), domains: make(map[string]*happydns.Domain), domainLogs: make(map[string]*happydns.DomainLogWithDomainId), domainLogsByDomains: make(map[string][]*happydns.Identifier), diff --git a/internal/storage/interface.go b/internal/storage/interface.go index 9b654e62..4f07593e 100644 --- a/internal/storage/interface.go +++ b/internal/storage/interface.go @@ -23,6 +23,8 @@ package storage // import "git.happydns.org/happyDomain/internal/storage" import ( "git.happydns.org/happyDomain/internal/usecase/authuser" + "git.happydns.org/happyDomain/internal/usecase/check" + "git.happydns.org/happyDomain/internal/usecase/checkresult" "git.happydns.org/happyDomain/internal/usecase/domain" "git.happydns.org/happyDomain/internal/usecase/domain_log" "git.happydns.org/happyDomain/internal/usecase/insight" @@ -43,8 +45,10 @@ type Storage interface { domain.DomainStorage domainlog.DomainLogStorage insight.InsightStorage + check.CheckerStorage provider.ProviderStorage session.SessionStorage + checkresult.CheckResultStorage user.UserStorage zone.ZoneStorage diff --git a/internal/storage/kvtpl/check.go b/internal/storage/kvtpl/check.go new file mode 100644 index 00000000..49e2b33c --- /dev/null +++ b/internal/storage/kvtpl/check.go @@ -0,0 +1,185 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package database + +import ( + "errors" + "fmt" + "strings" + + "git.happydns.org/happyDomain/model" +) + +func (s *KVStorage) ListAllCheckerConfigurations() (happydns.Iterator[happydns.CheckerOptions], error) { + iter := s.db.Search("chckrcfg-") + return NewKVIterator[happydns.CheckerOptions](s.db, iter), nil +} + +func buildCheckerKey(cname string, user *happydns.Identifier, domain *happydns.Identifier, service *happydns.Identifier) string { + u := "" + if user != nil { + u = user.String() + } + + d := "" + if domain != nil { + d = domain.String() + } + + s := "" + if service != nil { + s = service.String() + } + + return strings.Join([]string{cname, u, d, s}, "/") +} + +func keyToPositional(key string, opts *happydns.CheckerOptions) (*happydns.CheckerOptionsPositional, error) { + tmp := strings.Split(key, "/") + + if len(tmp) < 4 { + return nil, fmt.Errorf("malformed plugin configuration key, got %q", key) + } + + cname := tmp[0] + + var userid *happydns.Identifier + if len(tmp[1]) > 0 { + u, err := happydns.NewIdentifierFromString(tmp[1]) + if err != nil { + return nil, err + } + userid = &u + } + + var domainid *happydns.Identifier + if len(tmp[2]) > 0 { + d, err := happydns.NewIdentifierFromString(tmp[2]) + if err != nil { + return nil, err + } + domainid = &d + } + + var serviceid *happydns.Identifier + if len(tmp[3]) > 0 { + s, err := happydns.NewIdentifierFromString(tmp[3]) + if err != nil { + return nil, err + } + serviceid = &s + } + + return &happydns.CheckerOptionsPositional{ + CheckName: cname, + UserId: userid, + DomainId: domainid, + ServiceId: serviceid, + Options: *opts, + }, nil +} + +func (s *KVStorage) ListCheckerConfiguration(cname string) (configs []*happydns.CheckerOptionsPositional, err error) { + iter := s.db.Search("chckrcfg-" + cname + "/") + defer iter.Release() + + for iter.Next() { + var p happydns.CheckerOptions + + e := s.db.DecodeData(iter.Value(), &p) + if e != nil { + err = errors.Join(err, e) + continue + } + + opts, e := keyToPositional(strings.TrimPrefix(iter.Key(), "chckrcfg-"), &p) + if e != nil { + err = errors.Join(err, e) + continue + } + + configs = append(configs, opts) + } + + return +} + +func (s *KVStorage) GetCheckerConfiguration(cname string, user *happydns.Identifier, domain *happydns.Identifier, service *happydns.Identifier) (configs []*happydns.CheckerOptionsPositional, err error) { + iter := s.db.Search("chckrcfg-" + cname + "/") + defer iter.Release() + + for iter.Next() { + var p happydns.CheckerOptions + + e := s.db.DecodeData(iter.Value(), &p) + if e != nil { + err = errors.Join(err, e) + continue + } + + opts, e := keyToPositional(strings.TrimPrefix(iter.Key(), "chckrcfg-"), &p) + if e != nil { + err = errors.Join(err, e) + continue + } + + // Match logic: + // - When parameter is nil: match ONLY configs with nil ID (requesting specific scope) + // - When parameter is not nil: match configs with nil ID (admin-level) OR matching ID + matchUser := (user == nil && opts.UserId == nil) || + (user != nil && (opts.UserId == nil || opts.UserId.Equals(*user))) + + matchDomain := (domain == nil && opts.DomainId == nil) || + (domain != nil && (opts.DomainId == nil || opts.DomainId.Equals(*domain))) + + matchService := (service == nil && opts.ServiceId == nil) || + (service != nil && (opts.ServiceId == nil || opts.ServiceId.Equals(*service))) + + if matchUser && matchDomain && matchService { + configs = append(configs, opts) + } + } + + return +} + +func (s *KVStorage) UpdateCheckerConfiguration(cname string, user *happydns.Identifier, domain *happydns.Identifier, service *happydns.Identifier, opts happydns.CheckerOptions) error { + return s.db.Put(fmt.Sprintf("chckrcfg-%s", buildCheckerKey(cname, user, domain, service)), opts) +} + +func (s *KVStorage) DeleteCheckerConfiguration(cname string, user *happydns.Identifier, domain *happydns.Identifier, service *happydns.Identifier) error { + return s.db.Delete(fmt.Sprintf("chckrcfg-%s", buildCheckerKey(cname, user, domain, service))) +} + +func (s *KVStorage) ClearCheckerConfigurations() error { + iter := s.db.Search("chckrcfg-") + defer iter.Release() + + for iter.Next() { + err := s.db.Delete(iter.Key()) + if err != nil { + return err + } + } + + return nil +} diff --git a/internal/storage/kvtpl/checkresult.go b/internal/storage/kvtpl/checkresult.go new file mode 100644 index 00000000..0f336d39 --- /dev/null +++ b/internal/storage/kvtpl/checkresult.go @@ -0,0 +1,433 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package database + +import ( + "errors" + "fmt" + "sort" + "strings" + "time" + + "git.happydns.org/happyDomain/model" +) + +// Check Result storage keys: +// checkresult|{plugin-name}|{target-type}|{target-id}|{result-id} +func makeCheckResultKey(checkName string, targetType happydns.CheckScopeType, targetId, resultId happydns.Identifier) string { + return fmt.Sprintf("checkresult|%s|%d|%s|%s", checkName, targetType, targetId.String(), resultId.String()) +} + +func makeCheckResultPrefix(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier) string { + return fmt.Sprintf("checkresult|%s|%d|%s|", checkName, targetType, targetId.String()) +} + +// ListCheckResults retrieves check results for a specific plugin+target combination +func (s *KVStorage) ListCheckResults(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, limit int) ([]*happydns.CheckResult, error) { + prefix := makeCheckResultPrefix(checkName, targetType, targetId) + iter := s.db.Search(prefix) + defer iter.Release() + + var results []*happydns.CheckResult + for iter.Next() { + var r happydns.CheckResult + if err := s.db.DecodeData(iter.Value(), &r); err != nil { + return nil, err + } + results = append(results, &r) + } + + // Sort by ExecutedAt descending (most recent first) + sort.Slice(results, func(i, j int) bool { + return results[i].ExecutedAt.After(results[j].ExecutedAt) + }) + + // Apply limit + if limit > 0 && len(results) > limit { + results = results[:limit] + } + + return results, nil +} + +// ListCheckResultsByPlugin retrieves all check results for a plugin across all targets for a user +func (s *KVStorage) ListCheckResultsByPlugin(userId happydns.Identifier, checkName string, limit int) ([]*happydns.CheckResult, error) { + prefix := fmt.Sprintf("checkresult|%s|", checkName) + iter := s.db.Search(prefix) + defer iter.Release() + + var results []*happydns.CheckResult + for iter.Next() { + var r happydns.CheckResult + if err := s.db.DecodeData(iter.Value(), &r); err != nil { + return nil, err + } + // Filter by user + if r.OwnerId.Equals(userId) { + results = append(results, &r) + } + } + + // Sort by ExecutedAt descending (most recent first) + sort.Slice(results, func(i, j int) bool { + return results[i].ExecutedAt.After(results[j].ExecutedAt) + }) + + // Apply limit + if limit > 0 && len(results) > limit { + results = results[:limit] + } + + return results, nil +} + +// ListCheckResultsByUser retrieves all check results for a user +func (s *KVStorage) ListCheckResultsByUser(userId happydns.Identifier, limit int) ([]*happydns.CheckResult, error) { + iter := s.db.Search("checkresult|") + defer iter.Release() + + var results []*happydns.CheckResult + for iter.Next() { + var r happydns.CheckResult + if err := s.db.DecodeData(iter.Value(), &r); err != nil { + return nil, err + } + // Filter by user + if r.OwnerId.Equals(userId) { + results = append(results, &r) + } + } + + // Sort by ExecutedAt descending (most recent first) + sort.Slice(results, func(i, j int) bool { + return results[i].ExecutedAt.After(results[j].ExecutedAt) + }) + + // Apply limit + if limit > 0 && len(results) > limit { + results = results[:limit] + } + + return results, nil +} + +// GetCheckResult retrieves a specific check result by its ID +func (s *KVStorage) GetCheckResult(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, resultId happydns.Identifier) (*happydns.CheckResult, error) { + key := makeCheckResultKey(checkName, targetType, targetId, resultId) + var result happydns.CheckResult + err := s.db.Get(key, &result) + if errors.Is(err, happydns.ErrNotFound) { + return nil, happydns.ErrCheckResultNotFound + } + return &result, err +} + +// CreateCheckResult stores a new check result +func (s *KVStorage) CreateCheckResult(result *happydns.CheckResult) error { + prefix := makeCheckResultPrefix(result.CheckerName, result.CheckType, result.TargetId) + key, id, err := s.db.FindIdentifierKey(prefix) + if err != nil { + return err + } + + result.Id = id + return s.db.Put(key, result) +} + +// DeleteCheckResult removes a specific check result +func (s *KVStorage) DeleteCheckResult(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, resultId happydns.Identifier) error { + key := makeCheckResultKey(checkName, targetType, targetId, resultId) + return s.db.Delete(key) +} + +// DeleteOldCheckResults removes old check results keeping only the most recent N results +func (s *KVStorage) DeleteOldCheckResults(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, keepCount int) error { + results, err := s.ListCheckResults(checkName, targetType, targetId, 0) + if err != nil { + return err + } + + // Results are already sorted by ExecutedAt descending + // Delete results beyond keepCount + if len(results) > keepCount { + for _, r := range results[keepCount:] { + if err := s.DeleteCheckResult(checkName, targetType, targetId, r.Id); err != nil { + return err + } + } + } + + return nil +} + +// Checker Schedule storage keys: +// checkschedule|{schedule-id} +// checkschedule.byuser|{user-id}|{schedule-id} +// checkschedule.bytarget|{target-type}|{target-id}|{schedule-id} + +func makeCheckerScheduleKey(scheduleId happydns.Identifier) string { + return fmt.Sprintf("checkschedule|%s", scheduleId.String()) +} + +func makeCheckerScheduleUserIndexKey(userId, scheduleId happydns.Identifier) string { + return fmt.Sprintf("checkschedule.byuser|%s|%s", userId.String(), scheduleId.String()) +} + +func makeCheckerScheduleTargetIndexKey(targetType happydns.CheckScopeType, targetId, scheduleId happydns.Identifier) string { + return fmt.Sprintf("checkschedule.bytarget|%d|%s|%s", targetType, targetId.String(), scheduleId.String()) +} + +// ListEnabledCheckerSchedules retrieves all enabled schedules +func (s *KVStorage) ListEnabledCheckerSchedules() ([]*happydns.CheckerSchedule, error) { + iter := s.db.Search("checkschedule|") + defer iter.Release() + + var schedules []*happydns.CheckerSchedule + for iter.Next() { + var sched happydns.CheckerSchedule + if err := s.db.DecodeData(iter.Value(), &sched); err != nil { + return nil, err + } + if sched.Enabled { + schedules = append(schedules, &sched) + } + } + + return schedules, nil +} + +// ListCheckerSchedulesByUser retrieves all schedules for a specific user +func (s *KVStorage) ListCheckerSchedulesByUser(userId happydns.Identifier) ([]*happydns.CheckerSchedule, error) { + prefix := fmt.Sprintf("checkschedule.byuser|%s|", userId.String()) + iter := s.db.Search(prefix) + defer iter.Release() + + var schedules []*happydns.CheckerSchedule + for iter.Next() { + // Extract schedule ID from index key + key := string(iter.Key()) + parts := strings.Split(key, "|") + if len(parts) < 3 { + continue + } + + scheduleId, err := happydns.NewIdentifierFromString(parts[2]) + if err != nil { + continue + } + + // Get the actual schedule + var sched happydns.CheckerSchedule + schedKey := makeCheckerScheduleKey(scheduleId) + if err := s.db.Get(schedKey, &sched); err != nil { + continue + } + + schedules = append(schedules, &sched) + } + + return schedules, nil +} + +// ListCheckerSchedulesByTarget retrieves all schedules for a specific target +func (s *KVStorage) ListCheckerSchedulesByTarget(targetType happydns.CheckScopeType, targetId happydns.Identifier) ([]*happydns.CheckerSchedule, error) { + prefix := fmt.Sprintf("checkschedule.bytarget|%d|%s|", targetType, targetId.String()) + iter := s.db.Search(prefix) + defer iter.Release() + + var schedules []*happydns.CheckerSchedule + for iter.Next() { + // Extract schedule ID from index key + key := string(iter.Key()) + parts := strings.Split(key, "|") + if len(parts) < 4 { + continue + } + + scheduleId, err := happydns.NewIdentifierFromString(parts[3]) + if err != nil { + continue + } + + // Get the actual schedule + var sched happydns.CheckerSchedule + schedKey := makeCheckerScheduleKey(scheduleId) + if err := s.db.Get(schedKey, &sched); err != nil { + continue + } + + schedules = append(schedules, &sched) + } + + return schedules, nil +} + +// GetCheckerSchedule retrieves a specific schedule by ID +func (s *KVStorage) GetCheckerSchedule(scheduleId happydns.Identifier) (*happydns.CheckerSchedule, error) { + key := makeCheckerScheduleKey(scheduleId) + var schedule happydns.CheckerSchedule + err := s.db.Get(key, &schedule) + if errors.Is(err, happydns.ErrNotFound) { + return nil, happydns.ErrCheckScheduleNotFound + } + return &schedule, err +} + +// CreateCheckerSchedule creates a new check schedule +func (s *KVStorage) CreateCheckerSchedule(schedule *happydns.CheckerSchedule) error { + key, id, err := s.db.FindIdentifierKey("checkschedule|") + if err != nil { + return err + } + + schedule.Id = id + + // Store the schedule + if err := s.db.Put(key, schedule); err != nil { + return err + } + + // Create indexes + userIndexKey := makeCheckerScheduleUserIndexKey(schedule.OwnerId, schedule.Id) + if err := s.db.Put(userIndexKey, []byte{}); err != nil { + return err + } + + targetIndexKey := makeCheckerScheduleTargetIndexKey(schedule.TargetType, schedule.TargetId, schedule.Id) + if err := s.db.Put(targetIndexKey, []byte{}); err != nil { + return err + } + + return nil +} + +// UpdateCheckerSchedule updates an existing schedule +func (s *KVStorage) UpdateCheckerSchedule(schedule *happydns.CheckerSchedule) error { + key := makeCheckerScheduleKey(schedule.Id) + return s.db.Put(key, schedule) +} + +// DeleteCheckerSchedule removes a schedule and its indexes +func (s *KVStorage) DeleteCheckerSchedule(scheduleId happydns.Identifier) error { + // Get the schedule first to know what indexes to delete + schedule, err := s.GetCheckerSchedule(scheduleId) + if err != nil { + return err + } + + // Delete indexes + userIndexKey := makeCheckerScheduleUserIndexKey(schedule.OwnerId, schedule.Id) + if err := s.db.Delete(userIndexKey); err != nil { + return err + } + + targetIndexKey := makeCheckerScheduleTargetIndexKey(schedule.TargetType, schedule.TargetId, schedule.Id) + if err := s.db.Delete(targetIndexKey); err != nil { + return err + } + + // Delete the schedule itself + key := makeCheckerScheduleKey(scheduleId) + return s.db.Delete(key) +} + +// Check Execution storage keys: +// checkexec|{execution-id} + +func makeCheckExecutionKey(executionId happydns.Identifier) string { + return fmt.Sprintf("checkexec|%s", executionId.String()) +} + +// ListActiveCheckExecutions retrieves all executions that are pending or running +func (s *KVStorage) ListActiveCheckExecutions() ([]*happydns.CheckExecution, error) { + iter := s.db.Search("checkexec|") + defer iter.Release() + + var executions []*happydns.CheckExecution + for iter.Next() { + var exec happydns.CheckExecution + if err := s.db.DecodeData(iter.Value(), &exec); err != nil { + return nil, err + } + if exec.Status == happydns.CheckExecutionPending || exec.Status == happydns.CheckExecutionRunning { + executions = append(executions, &exec) + } + } + + return executions, nil +} + +// GetCheckExecution retrieves a specific execution by ID +func (s *KVStorage) GetCheckExecution(executionId happydns.Identifier) (*happydns.CheckExecution, error) { + key := makeCheckExecutionKey(executionId) + var execution happydns.CheckExecution + err := s.db.Get(key, &execution) + if errors.Is(err, happydns.ErrNotFound) { + return nil, happydns.ErrCheckExecutionNotFound + } + return &execution, err +} + +// CreateCheckExecution creates a new check execution record +func (s *KVStorage) CreateCheckExecution(execution *happydns.CheckExecution) error { + key, id, err := s.db.FindIdentifierKey("checkexec|") + if err != nil { + return err + } + + execution.Id = id + return s.db.Put(key, execution) +} + +// UpdateCheckExecution updates an existing execution record +func (s *KVStorage) UpdateCheckExecution(execution *happydns.CheckExecution) error { + key := makeCheckExecutionKey(execution.Id) + return s.db.Put(key, execution) +} + +// DeleteCheckExecution removes an execution record +func (s *KVStorage) DeleteCheckExecution(executionId happydns.Identifier) error { + key := makeCheckExecutionKey(executionId) + return s.db.Delete(key) +} + +// Scheduler state storage key: +// checkscheduler.lastrun + +// CheckerSchedulerRun marks that the scheduler has run at current time +func (s *KVStorage) CheckSchedulerRun() error { + now := time.Now() + return s.db.Put("checkscheduler.lastrun", &now) +} + +// LastCheckSchedulerRun retrieves the last time the scheduler ran +func (s *KVStorage) LastCheckSchedulerRun() (*time.Time, error) { + var lastRun time.Time + err := s.db.Get("checkscheduler.lastrun", &lastRun) + if errors.Is(err, happydns.ErrNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &lastRun, nil +} diff --git a/internal/usecase/check/check_storage.go b/internal/usecase/check/check_storage.go new file mode 100644 index 00000000..d1807c18 --- /dev/null +++ b/internal/usecase/check/check_storage.go @@ -0,0 +1,62 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package check + +import ( + "git.happydns.org/happyDomain/model" +) + +type CheckerStorage interface { + // ListAllCheckConfigurations retrieves the list of known Providers. + ListAllCheckerConfigurations() (happydns.Iterator[happydns.CheckerOptions], error) + + // ListCheckerConfiguration retrieves all providers own by the given User. + ListCheckerConfiguration(string) ([]*happydns.CheckerOptionsPositional, error) + + // GetCheckerConfiguration retrieves the full Provider with the given identifier and owner. + GetCheckerConfiguration(string, *happydns.Identifier, *happydns.Identifier, *happydns.Identifier) ([]*happydns.CheckerOptionsPositional, error) + + // UpdateCheckerConfiguration updates the fields of the given Provider. + UpdateCheckerConfiguration(string, *happydns.Identifier, *happydns.Identifier, *happydns.Identifier, happydns.CheckerOptions) error + + // DeleteCheckerConfiguration removes the given Provider from the database. + DeleteCheckerConfiguration(string, *happydns.Identifier, *happydns.Identifier, *happydns.Identifier) error + + // ClearCheckerConfigurations deletes all Providers present in the database. + ClearCheckerConfigurations() error +} + +// CheckAutoFillStorage provides the domain/zone/user lookups needed to +// resolve auto-fill variables for test check options. +type CheckAutoFillStorage interface { + // GetDomain retrieves the Domain with the given identifier. + GetDomain(domainid happydns.Identifier) (*happydns.Domain, error) + + // GetUser retrieves the User with the given identifier. + GetUser(userid happydns.Identifier) (*happydns.User, error) + + // ListDomains retrieves all Domains associated to the given User. + ListDomains(user *happydns.User) ([]*happydns.Domain, error) + + // GetZone retrieves the full Zone (including Services and metadata) for the given identifier. + GetZone(zoneid happydns.Identifier) (*happydns.ZoneMessage, error) +} diff --git a/internal/usecase/check/check_usecase.go b/internal/usecase/check/check_usecase.go new file mode 100644 index 00000000..33a05a94 --- /dev/null +++ b/internal/usecase/check/check_usecase.go @@ -0,0 +1,318 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package check + +import ( + "fmt" + "log" + "maps" + "sort" + + "git.happydns.org/happyDomain/checks" + "git.happydns.org/happyDomain/model" +) + +type checkerUsecase struct { + config *happydns.Options + store CheckerStorage + autoFillStore CheckAutoFillStorage +} + +func NewCheckerUsecase(cfg *happydns.Options, store CheckerStorage, autoFillStore CheckAutoFillStorage) happydns.CheckerUsecase { + return &checkerUsecase{ + config: cfg, + store: store, + autoFillStore: autoFillStore, + } +} + +func (tu *checkerUsecase) GetChecker(cname string) (happydns.Checker, error) { + checker, err := checks.FindChecker(cname) + if err != nil { + return nil, fmt.Errorf("unable to find check named %q: %w", cname, err) + } + + return checker, nil +} + +type ByOptionPosition []*happydns.CheckerOptionsPositional + +func (a ByOptionPosition) Len() int { return len(a) } +func (a ByOptionPosition) Swap(i, j int) { a[i], a[j] = a[j], a[i] } +func (a ByOptionPosition) Less(i, j int) bool { + if a[i].CheckName != a[j].CheckName { + return a[i].CheckName < a[j].CheckName + } + + if res := compareIdentifiers(a[i].UserId, a[j].UserId); res != 0 { + return res < 0 + } + + if res := compareIdentifiers(a[i].DomainId, a[j].DomainId); res != 0 { + return res < 0 + } + + if res := compareIdentifiers(a[i].ServiceId, a[j].ServiceId); res != 0 { + return res < 0 + } + + return false +} + +func compareIdentifiers(a, b *happydns.Identifier) int { + if a == nil && b == nil { + return 0 + } + if a == nil { + return -1 + } + if b == nil { + return 1 + } + + if a.Equals(*b) { + return 0 + } + + return a.Compare(*b) +} + +func (tu *checkerUsecase) GetCheckerOptions(cname string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier) (*happydns.CheckerOptions, error) { + configs, err := tu.store.GetCheckerConfiguration(cname, userid, domainid, serviceid) + if err != nil { + return nil, err + } + + sort.Sort(ByOptionPosition(configs)) + + opts := make(happydns.CheckerOptions) + + for _, c := range configs { + maps.Copy(opts, c.Options) + } + + return &opts, nil +} + +func (tu *checkerUsecase) ListCheckers() (*map[string]happydns.Checker, error) { + return checks.GetCheckers(), nil +} + +// GetStoredCheckerOptionsNoDefault returns the stored options (user/domain/service scopes) +// with auto-fill variables applied, but without checker-defined defaults or run-time overrides. +func (tu *checkerUsecase) GetStoredCheckerOptionsNoDefault(cname string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier) (happydns.CheckerOptions, error) { + stored, err := tu.GetCheckerOptions(cname, userid, domainid, serviceid) + if err != nil { + return nil, err + } + + var opts happydns.CheckerOptions + if stored != nil { + opts = *stored + } else { + opts = make(happydns.CheckerOptions) + } + + checker, err := tu.GetChecker(cname) + if err != nil { + return opts, nil + } + + return tu.applyAutoFill(checker, userid, domainid, serviceid, opts), nil +} + +// BuildMergedCheckerOptions merges checker options from all sources in priority order: +// checker defaults < stored (user/domain/service) options < runOpts < auto-fill variables. +func (tu *checkerUsecase) BuildMergedCheckerOptions(cname string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier, runOpts happydns.CheckerOptions) (happydns.CheckerOptions, error) { + merged := make(happydns.CheckerOptions) + + // 1. Fill checker defaults. + checker, err := tu.GetChecker(cname) + if err != nil { + log.Printf("Warning: unable to get checker %q for default options: %v", cname, err) + } else { + opts := checker.Options() + + allOpts := []happydns.CheckerOptionDocumentation{} + allOpts = append(allOpts, opts.RunOpts...) + allOpts = append(allOpts, opts.ServiceOpts...) + allOpts = append(allOpts, opts.DomainOpts...) + allOpts = append(allOpts, opts.UserOpts...) + allOpts = append(allOpts, opts.AdminOpts...) + for _, opt := range allOpts { + if opt.Default != nil { + merged[opt.Id] = opt.Default + } + } + } + + // 2. Override with stored options (user/domain/service scopes). + baseOptions, err := tu.GetCheckerOptions(cname, userid, domainid, serviceid) + if err != nil { + return merged, fmt.Errorf("could not fetch stored checker options for %s: %w", cname, err) + } + if baseOptions != nil { + maps.Copy(merged, *baseOptions) + } + + // 3. Override with caller-supplied run options. + maps.Copy(merged, runOpts) + + // 4. Inject auto-fill variables (always win over any user-supplied value). + if checker != nil { + merged = tu.applyAutoFill(checker, userid, domainid, serviceid, merged) + } + + return merged, nil +} + +// applyAutoFill resolves auto-fill fields declared by the checker and injects +// the context-resolved values into a copy of opts. +func (tu *checkerUsecase) applyAutoFill( + checker happydns.Checker, + userid *happydns.Identifier, + domainid *happydns.Identifier, + serviceid *happydns.Identifier, + opts happydns.CheckerOptions, +) happydns.CheckerOptions { + // Collect which auto-fill keys are needed. + needed := make(map[string]string) // autoFill constant → field id + options := checker.Options() + for _, groups := range [][]happydns.CheckerOptionDocumentation{ + options.RunOpts, options.DomainOpts, options.ServiceOpts, + options.UserOpts, options.AdminOpts, + } { + for _, opt := range groups { + if opt.AutoFill != "" { + needed[opt.AutoFill] = opt.Id + } + } + } + + if len(needed) == 0 || tu.autoFillStore == nil { + return opts + } + + autoFillCtx := tu.buildAutoFillContext(userid, domainid, serviceid) + + result := maps.Clone(opts) + for autoFillKey, fieldId := range needed { + if val, ok := autoFillCtx[autoFillKey]; ok { + result[fieldId] = val + } + } + return result +} + +// buildAutoFillContext resolves the available auto-fill values for the given +// user/domain/service identifiers. +func (tu *checkerUsecase) buildAutoFillContext(userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier) map[string]string { + ctx := make(map[string]string) + + if domainid != nil { + if domain, err := tu.autoFillStore.GetDomain(*domainid); err == nil { + ctx[happydns.AutoFillDomainName] = domain.DomainName + } + } else if serviceid != nil && userid != nil { + // To resolve service context we need to find which domain/zone owns the service. + user, err := tu.autoFillStore.GetUser(*userid) + if err != nil { + return ctx + } + domains, err := tu.autoFillStore.ListDomains(user) + if err != nil { + return ctx + } + for _, domain := range domains { + if len(domain.ZoneHistory) == 0 { + continue + } + // The first element in ZoneHistory is the current (most recent) zone. + zoneMsg, err := tu.autoFillStore.GetZone(domain.ZoneHistory[0]) + if err != nil { + continue + } + for subdomain, svcs := range zoneMsg.Services { + for _, svc := range svcs { + if svc.Id.Equals(*serviceid) { + ctx[happydns.AutoFillDomainName] = domain.DomainName + ctx[happydns.AutoFillSubdomain] = string(subdomain) + ctx[happydns.AutoFillServiceType] = svc.Type + return ctx + } + } + } + } + } + + return ctx +} + +func (tu *checkerUsecase) SetCheckerOptions(cname string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier, opts happydns.CheckerOptions) error { + // filter opts that correspond to the level set + checker, err := tu.GetChecker(cname) + if err != nil { + return fmt.Errorf("unable to get checker: %w", err) + } + + options := checker.Options() + + var optNames []string + if serviceid != nil { + for _, opt := range options.ServiceOpts { + optNames = append(optNames, opt.Id) + } + } else if domainid != nil { + for _, opt := range options.DomainOpts { + optNames = append(optNames, opt.Id) + } + } else if userid != nil { + for _, opt := range options.UserOpts { + optNames = append(optNames, opt.Id) + } + } else { + for _, opt := range options.AdminOpts { + optNames = append(optNames, opt.Id) + } + } + + // Filter opts to only include keys that are in optNames + filteredOpts := make(happydns.CheckerOptions) + for _, optName := range optNames { + if val, exists := opts[optName]; exists && val != "" { + filteredOpts[optName] = val + } + } + + return tu.store.UpdateCheckerConfiguration(cname, userid, domainid, serviceid, opts) +} + +func (tu *checkerUsecase) OverwriteSomeCheckerOptions(cname string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier, opts happydns.CheckerOptions) error { + current, err := tu.GetCheckerOptions(cname, userid, domainid, serviceid) + if err != nil { + return err + } + + maps.Copy(*current, opts) + + return tu.store.UpdateCheckerConfiguration(cname, userid, domainid, serviceid, *current) +} diff --git a/internal/usecase/check/check_usecase_test.go b/internal/usecase/check/check_usecase_test.go new file mode 100644 index 00000000..5c30d82c --- /dev/null +++ b/internal/usecase/check/check_usecase_test.go @@ -0,0 +1,85 @@ +package check_test + +import ( + "sort" + "testing" + + uc "git.happydns.org/happyDomain/internal/usecase/check" + "git.happydns.org/happyDomain/model" +) + +func TestSortByCheckName(t *testing.T) { + slice := []*happydns.CheckerOptionsPositional{ + {CheckName: "zeta"}, + {CheckName: "alpha"}, + {CheckName: "beta"}, + } + + sort.Sort(uc.ByOptionPosition(slice)) + + got := []string{slice[0].CheckName, slice[1].CheckName, slice[2].CheckName} + want := []string{"alpha", "beta", "zeta"} + + for i := range want { + if got[i] != want[i] { + t.Errorf("expected %v, got %v", want, got) + break + } + } +} + +func TestNilBeforeNonNil(t *testing.T) { + uid, _ := happydns.NewRandomIdentifier() + slice := []*happydns.CheckerOptionsPositional{ + {CheckName: "alpha", UserId: &uid}, + {CheckName: "alpha", UserId: nil}, + } + + sort.Sort(uc.ByOptionPosition(slice)) + + if slice[0].UserId != nil { + t.Errorf("expected nil UserId first, got %+v", slice[0].UserId) + } +} + +func TestDomainIdOrder(t *testing.T) { + did, _ := happydns.NewRandomIdentifier() + slice := []*happydns.CheckerOptionsPositional{ + {CheckName: "alpha", UserId: nil, DomainId: &did}, + {CheckName: "alpha", UserId: nil, DomainId: nil}, + } + + sort.Sort(uc.ByOptionPosition(slice)) + + if slice[0].DomainId != nil { + t.Errorf("expected nil DomainId first, got %+v", slice[0].DomainId) + } +} + +func TestServiceIdOrder(t *testing.T) { + sid, _ := happydns.NewRandomIdentifier() + slice := []*happydns.CheckerOptionsPositional{ + {CheckName: "alpha", UserId: nil, DomainId: nil, ServiceId: &sid}, + {CheckName: "alpha", UserId: nil, DomainId: nil, ServiceId: nil}, + } + + sort.Sort(uc.ByOptionPosition(slice)) + + if slice[0].ServiceId != nil { + t.Errorf("expected nil ServiceId first, got %+v", slice[0].ServiceId) + } +} + +func TestStableGrouping(t *testing.T) { + uid, _ := happydns.NewRandomIdentifier() + + slice := []*happydns.CheckerOptionsPositional{ + {CheckName: "alpha", UserId: &uid}, + {CheckName: "alpha", UserId: &uid}, + } + + sort.Sort(uc.ByOptionPosition(slice)) + if slice[0].CheckName != slice[1].CheckName { + t.Errorf("expected grouping, got %+v vs %+v", slice[0], slice[1]) + } +} diff --git a/internal/usecase/checkresult/checkresult_usecase.go b/internal/usecase/checkresult/checkresult_usecase.go new file mode 100644 index 00000000..f7aea9d2 --- /dev/null +++ b/internal/usecase/checkresult/checkresult_usecase.go @@ -0,0 +1,222 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package checkresult + +import ( + "fmt" + "time" + + "git.happydns.org/happyDomain/model" +) + +// CheckResultUsecase implements business logic for check results +type CheckResultUsecase struct { + storage CheckResultStorage + options *happydns.Options +} + +// NewCheckResultUsecase creates a new check result usecase +func NewCheckResultUsecase(storage CheckResultStorage, options *happydns.Options) *CheckResultUsecase { + return &CheckResultUsecase{ + storage: storage, + options: options, + } +} + +// ListCheckResultsByTarget retrieves check results for a specific target +func (u *CheckResultUsecase) ListCheckResultsByTarget(pluginName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, limit int) ([]*happydns.CheckResult, error) { + // Apply default limit if not specified + if limit <= 0 { + limit = 5 // Default to 5 most recent results + } + + return u.storage.ListCheckResults(pluginName, targetType, targetId, limit) +} + +// ListAllCheckResultsByTarget retrieves all check results for a target across all plugins +func (u *CheckResultUsecase) ListAllCheckResultsByTarget(targetType happydns.CheckScopeType, targetId happydns.Identifier, userId happydns.Identifier, limit int) ([]*happydns.CheckResult, error) { + // Get all results for the user and filter by target + allResults, err := u.storage.ListCheckResultsByUser(userId, 0) + if err != nil { + return nil, err + } + + // Filter by target + var results []*happydns.CheckResult + for _, r := range allResults { + if r.CheckType == targetType && r.TargetId.Equals(targetId) { + results = append(results, r) + } + } + + // Apply limit + if limit > 0 && len(results) > limit { + results = results[:limit] + } + + return results, nil +} + +// GetCheckResult retrieves a specific check result +func (u *CheckResultUsecase) GetCheckResult(pluginName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, resultId happydns.Identifier) (*happydns.CheckResult, error) { + return u.storage.GetCheckResult(pluginName, targetType, targetId, resultId) +} + +// CreateCheckResult stores a new check result and enforces retention policy +func (u *CheckResultUsecase) CreateCheckResult(result *happydns.CheckResult) error { + // Store the result + if err := u.storage.CreateCheckResult(result); err != nil { + return err + } + + // Enforce retention policy + maxResults := u.options.MaxResultsPerCheck + if maxResults <= 0 { + maxResults = 100 // Default + } + + return u.storage.DeleteOldCheckResults(result.CheckerName, result.CheckType, result.TargetId, maxResults) +} + +// DeleteCheckResult removes a specific check result +func (u *CheckResultUsecase) DeleteCheckResult(pluginName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, resultId happydns.Identifier) error { + return u.storage.DeleteCheckResult(pluginName, targetType, targetId, resultId) +} + +// DeleteAllCheckResults removes all results for a specific plugin+target combination +func (u *CheckResultUsecase) DeleteAllCheckResults(pluginName string, targetType happydns.CheckScopeType, targetId happydns.Identifier) error { + // Get all results first + results, err := u.storage.ListCheckResults(pluginName, targetType, targetId, 0) + if err != nil { + return err + } + + // Delete each result + for _, r := range results { + if err := u.storage.DeleteCheckResult(pluginName, targetType, targetId, r.Id); err != nil { + return err + } + } + + return nil +} + +// CleanupOldResults removes check results older than retention period +func (u *CheckResultUsecase) CleanupOldResults() error { + retentionDays := u.options.ResultRetentionDays + if retentionDays <= 0 { + retentionDays = 90 // Default + } + + cutoffTime := time.Now().AddDate(0, 0, -retentionDays) + + // Get all results for all users (inefficient but necessary without a time-based index) + // In a production system, you might want to add a time-based index for this + // For now, we'll iterate through results and delete old ones + + // This is a placeholder - the actual implementation would need to be optimized + // based on specific storage patterns + _ = cutoffTime + + return nil +} + +// GetCheckExecution retrieves the status of a check execution +func (u *CheckResultUsecase) GetCheckExecution(executionId happydns.Identifier) (*happydns.CheckExecution, error) { + return u.storage.GetCheckExecution(executionId) +} + +// CreateCheckExecution creates a new check execution record +func (u *CheckResultUsecase) CreateCheckExecution(execution *happydns.CheckExecution) error { + if execution.StartedAt.IsZero() { + execution.StartedAt = time.Now() + } + return u.storage.CreateCheckExecution(execution) +} + +// UpdateCheckExecution updates an existing check execution +func (u *CheckResultUsecase) UpdateCheckExecution(execution *happydns.CheckExecution) error { + return u.storage.UpdateCheckExecution(execution) +} + +// CompleteCheckExecution marks an execution as completed with a result +func (u *CheckResultUsecase) CompleteCheckExecution(executionId happydns.Identifier, resultId happydns.Identifier) error { + execution, err := u.storage.GetCheckExecution(executionId) + if err != nil { + return err + } + + now := time.Now() + execution.Status = happydns.CheckExecutionCompleted + execution.CompletedAt = &now + execution.ResultId = &resultId + + return u.storage.UpdateCheckExecution(execution) +} + +// FailCheckExecution marks an execution as failed +func (u *CheckResultUsecase) FailCheckExecution(executionId happydns.Identifier, errorMsg string) error { + execution, err := u.storage.GetCheckExecution(executionId) + if err != nil { + return err + } + + now := time.Now() + execution.Status = happydns.CheckExecutionFailed + execution.CompletedAt = &now + + // Store error in a result + result := &happydns.CheckResult{ + CheckerName: execution.CheckerName, + CheckType: execution.TargetType, + TargetId: execution.TargetId, + OwnerId: execution.OwnerId, + ExecutedAt: time.Now(), + ScheduledCheck: execution.ScheduleId != nil, + Options: execution.Options, + Status: happydns.CheckResultStatusKO, + StatusLine: "Execution failed", + Error: errorMsg, + Duration: now.Sub(execution.StartedAt), + } + + if err := u.CreateCheckResult(result); err != nil { + return fmt.Errorf("failed to create error result: %w", err) + } + + execution.ResultId = &result.Id + + return u.storage.UpdateCheckExecution(execution) +} + +// DeleteCompletedExecutions removes execution records that are completed +func (u *CheckResultUsecase) DeleteCompletedExecutions(olderThan time.Duration) error { + cutoffTime := time.Now().Add(-olderThan) + + // Get active executions (this won't include completed ones) + // We need a different query to get completed executions older than cutoff + // For now, this is a placeholder + + _ = cutoffTime + + return nil +} diff --git a/internal/usecase/checkresult/checkschedule_usecase.go b/internal/usecase/checkresult/checkschedule_usecase.go new file mode 100644 index 00000000..c6297ae7 --- /dev/null +++ b/internal/usecase/checkresult/checkschedule_usecase.go @@ -0,0 +1,410 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package checkresult + +import ( + "errors" + "fmt" + "math/rand" + "sort" + "time" + + "git.happydns.org/happyDomain/model" +) + +const ( + // Default check intervals + DefaultUserCheckInterval = 4 * time.Hour // 4 hours for user checks + DefaultDomainCheckInterval = 24 * time.Hour // 24 hours for domain checks + DefaultServiceCheckInterval = 1 * time.Hour // 1 hour for service checks + MinimumCheckInterval = 5 * time.Minute // Minimum interval allowed +) + +// CheckScheduleUsecase implements business logic for check schedules +type CheckScheduleUsecase struct { + storage CheckResultStorage + options *happydns.Options + domainLister DomainLister + checkerUsecase happydns.CheckerUsecase +} + +// NewCheckScheduleUsecase creates a new check schedule usecase +func NewCheckScheduleUsecase(storage CheckResultStorage, options *happydns.Options, domainLister DomainLister, checkerUsecase happydns.CheckerUsecase) *CheckScheduleUsecase { + return &CheckScheduleUsecase{ + storage: storage, + options: options, + domainLister: domainLister, + checkerUsecase: checkerUsecase, + } +} + +// ListUserSchedules retrieves all schedules for a specific user +func (u *CheckScheduleUsecase) ListUserSchedules(userId happydns.Identifier) ([]*happydns.CheckerSchedule, error) { + return u.storage.ListCheckerSchedulesByUser(userId) +} + +// ListSchedulesByTarget retrieves all schedules for a specific target +func (u *CheckScheduleUsecase) ListSchedulesByTarget(targetType happydns.CheckScopeType, targetId happydns.Identifier) ([]*happydns.CheckerSchedule, error) { + return u.storage.ListCheckerSchedulesByTarget(targetType, targetId) +} + +// GetSchedule retrieves a specific schedule by ID +func (u *CheckScheduleUsecase) GetSchedule(scheduleId happydns.Identifier) (*happydns.CheckerSchedule, error) { + return u.storage.GetCheckerSchedule(scheduleId) +} + +// CreateSchedule creates a new check schedule with validation +func (u *CheckScheduleUsecase) CreateSchedule(schedule *happydns.CheckerSchedule) error { + // Set default interval if not specified + if schedule.Interval == 0 { + schedule.Interval = u.getDefaultInterval(schedule.TargetType) + } + + // Validate interval + if schedule.Interval < MinimumCheckInterval { + return fmt.Errorf("check interval must be at least %v", MinimumCheckInterval) + } + + // Calculate next run time: pick a random offset within the interval + // to spread load evenly across all schedules + // TODO: Use a smarter load balance function in the future + if schedule.NextRun.IsZero() { + offset := time.Duration(rand.Int63n(int64(schedule.Interval))) + schedule.NextRun = time.Now().Add(offset) + } + + return u.storage.CreateCheckerSchedule(schedule) +} + +// UpdateSchedule updates an existing schedule +func (u *CheckScheduleUsecase) UpdateSchedule(schedule *happydns.CheckerSchedule) error { + // Validate interval + if schedule.Interval < MinimumCheckInterval { + return fmt.Errorf("check interval must be at least %v", MinimumCheckInterval) + } + + // Get existing schedule to preserve certain fields + existing, err := u.storage.GetCheckerSchedule(schedule.Id) + if err != nil { + return err + } + + // Preserve LastRun if not explicitly changed + if schedule.LastRun == nil { + schedule.LastRun = existing.LastRun + } + + // Recalculate next run time if interval changed + if schedule.Interval != existing.Interval { + if schedule.LastRun != nil { + schedule.NextRun = schedule.LastRun.Add(schedule.Interval) + } else { + schedule.NextRun = time.Now().Add(schedule.Interval) + } + } + + return u.storage.UpdateCheckerSchedule(schedule) +} + +// DeleteSchedule removes a schedule +func (u *CheckScheduleUsecase) DeleteSchedule(scheduleId happydns.Identifier) error { + return u.storage.DeleteCheckerSchedule(scheduleId) +} + +// EnableSchedule enables a schedule +func (u *CheckScheduleUsecase) EnableSchedule(scheduleId happydns.Identifier) error { + schedule, err := u.storage.GetCheckerSchedule(scheduleId) + if err != nil { + return err + } + + schedule.Enabled = true + + // Reset next run time if it's in the past + if schedule.NextRun.Before(time.Now()) { + schedule.NextRun = time.Now().Add(schedule.Interval) + } + + return u.storage.UpdateCheckerSchedule(schedule) +} + +// DisableSchedule disables a schedule +func (u *CheckScheduleUsecase) DisableSchedule(scheduleId happydns.Identifier) error { + schedule, err := u.storage.GetCheckerSchedule(scheduleId) + if err != nil { + return err + } + + schedule.Enabled = false + return u.storage.UpdateCheckerSchedule(schedule) +} + +// UpdateScheduleAfterRun updates a schedule after it has been executed +func (u *CheckScheduleUsecase) UpdateScheduleAfterRun(scheduleId happydns.Identifier) error { + schedule, err := u.storage.GetCheckerSchedule(scheduleId) + if err != nil { + return err + } + + now := time.Now() + schedule.LastRun = &now + schedule.NextRun = now.Add(schedule.Interval) + + return u.storage.UpdateCheckerSchedule(schedule) +} + +// ListDueSchedules retrieves all enabled schedules that are due to run +func (u *CheckScheduleUsecase) ListDueSchedules() ([]*happydns.CheckerSchedule, error) { + schedules, err := u.storage.ListEnabledCheckerSchedules() + if err != nil { + return nil, err + } + + now := time.Now() + var dueSchedules []*happydns.CheckerSchedule + + for _, schedule := range schedules { + if schedule.NextRun.Before(now) { + dueSchedules = append(dueSchedules, schedule) + } + } + + return dueSchedules, nil +} + +// ListUpcomingSchedules retrieves the next `limit` enabled schedules sorted by NextRun ascending +func (u *CheckScheduleUsecase) ListUpcomingSchedules(limit int) ([]*happydns.CheckerSchedule, error) { + schedules, err := u.storage.ListEnabledCheckerSchedules() + if err != nil { + return nil, err + } + + sort.Slice(schedules, func(i, j int) bool { + return schedules[i].NextRun.Before(schedules[j].NextRun) + }) + + if limit > 0 && len(schedules) > limit { + schedules = schedules[:limit] + } + + return schedules, nil +} + +// getDefaultInterval returns the default check interval based on target type +func (u *CheckScheduleUsecase) getDefaultInterval(targetType happydns.CheckScopeType) time.Duration { + switch targetType { + case happydns.CheckScopeUser: + return DefaultUserCheckInterval + case happydns.CheckScopeDomain: + return DefaultDomainCheckInterval + case happydns.CheckScopeService: + return DefaultServiceCheckInterval + default: + return DefaultDomainCheckInterval + } +} + +// ValidateScheduleOwnership checks if a user owns a schedule +func (u *CheckScheduleUsecase) ValidateScheduleOwnership(scheduleId happydns.Identifier, userId happydns.Identifier) error { + schedule, err := u.storage.GetCheckerSchedule(scheduleId) + if err != nil { + return err + } + + if !schedule.OwnerId.Equals(userId) { + return fmt.Errorf("user does not own this schedule") + } + + return nil +} + +// CreateDefaultSchedulesForTarget creates default schedules for a new target +func (u *CheckScheduleUsecase) CreateDefaultSchedulesForTarget( + checkerName string, + targetType happydns.CheckScopeType, + targetId happydns.Identifier, + ownerId happydns.Identifier, + enabled bool, +) error { + schedule := &happydns.CheckerSchedule{ + CheckerName: checkerName, + OwnerId: ownerId, + TargetType: targetType, + TargetId: targetId, + Interval: u.getDefaultInterval(targetType), + Enabled: enabled, + NextRun: time.Now().Add(u.getDefaultInterval(targetType)), + Options: make(happydns.CheckerOptions), + } + + return u.CreateSchedule(schedule) +} + +// rescheduleChecks reschedules each given schedule to a random time in [now, now+maxOffsetFn(schedule)]. +func (u *CheckScheduleUsecase) rescheduleChecks(schedules []*happydns.CheckerSchedule, maxOffsetFn func(*happydns.CheckerSchedule) time.Duration) (int, error) { + count := 0 + now := time.Now() + for _, schedule := range schedules { + maxOffset := maxOffsetFn(schedule) + if maxOffset <= 0 { + maxOffset = time.Second + } + schedule.NextRun = now.Add(time.Duration(rand.Int63n(int64(maxOffset)))) + if err := u.storage.UpdateCheckerSchedule(schedule); err != nil { + return count, err + } + count++ + } + return count, nil +} + +// RescheduleUpcomingChecks randomizes the next run time of all enabled schedules +// within their respective intervals to spread load evenly. Useful after a restart. +func (u *CheckScheduleUsecase) RescheduleUpcomingChecks() (int, error) { + schedules, err := u.storage.ListEnabledCheckerSchedules() + if err != nil { + return 0, err + } + return u.rescheduleChecks(schedules, func(s *happydns.CheckerSchedule) time.Duration { + return s.Interval + }) +} + +// RescheduleOverdueChecks reschedules checks whose NextRun is in the past, +// spreading them over a short window to avoid scheduler famine (e.g. after +// a long machine suspend or server downtime). +// If there are fewer than 10 overdue checks, they are left as-is so that the +// caller's immediate checkSchedules pass enqueues them directly. +func (u *CheckScheduleUsecase) RescheduleOverdueChecks() (int, error) { + schedules, err := u.storage.ListEnabledCheckerSchedules() + if err != nil { + return 0, err + } + + now := time.Now() + var overdue []*happydns.CheckerSchedule + for _, s := range schedules { + if s.NextRun.Before(now) { + overdue = append(overdue, s) + } + } + + if len(overdue) == 0 { + return 0, nil + } + + // Small backlog: let the caller enqueue them directly on the next + // checkSchedules pass rather than deferring them into the future. + if len(overdue) < 10 { + return 0, nil + } + + // Spread overdue checks over a small window proportional to their count, + // capped at MinimumCheckInterval, to prevent all of them from running at once. + spreadWindow := time.Duration(len(overdue)) * 5 * time.Second + if spreadWindow > MinimumCheckInterval { + spreadWindow = MinimumCheckInterval + } + + return u.rescheduleChecks(overdue, func(s *happydns.CheckerSchedule) time.Duration { + return spreadWindow + }) +} + +// DeleteSchedulesForTarget removes all schedules for a target +func (u *CheckScheduleUsecase) DeleteSchedulesForTarget(targetType happydns.CheckScopeType, targetId happydns.Identifier) error { + schedules, err := u.storage.ListCheckerSchedulesByTarget(targetType, targetId) + if err != nil { + return err + } + + for _, schedule := range schedules { + if err := u.storage.DeleteCheckerSchedule(schedule.Id); err != nil { + return err + } + } + + return nil +} + +// DiscoverAndEnsureSchedules creates default enabled schedules for all (plugin, domain) +// pairs that don't yet have an explicit schedule record. This implements the opt-out +// model: checks run automatically unless a schedule with Enabled=false has been saved. +// Non-fatal per-domain errors are collected and returned together. +func (u *CheckScheduleUsecase) DiscoverAndEnsureSchedules() error { + if u.domainLister == nil || u.checkerUsecase == nil { + return nil + } + + plugins, err := u.checkerUsecase.ListCheckers() + if err != nil { + return fmt.Errorf("listing check plugins for discovery: %w", err) + } + + iter, err := u.domainLister.ListAllDomains() + if err != nil { + return fmt.Errorf("listing domains for schedule discovery: %w", err) + } + defer iter.Close() + + var errs []error + for iter.Next() { + domain := iter.Item() + if domain == nil { + continue + } + for checkerName, p := range *plugins { + if !p.Availability().ApplyToDomain { + continue + } + + schedules, err := u.ListSchedulesByTarget(happydns.CheckScopeDomain, domain.Id) + if err != nil { + errs = append(errs, fmt.Errorf("listing schedules for domain %s: %w", domain.Id, err)) + continue + } + + hasSchedule := false + for _, sched := range schedules { + if sched.CheckerName == checkerName { + hasSchedule = true + break + } + } + + if !hasSchedule { + if err := u.CreateSchedule(&happydns.CheckerSchedule{ + CheckerName: checkerName, + OwnerId: domain.Owner, + TargetType: happydns.CheckScopeDomain, + TargetId: domain.Id, + Enabled: true, + }); err != nil { + errs = append(errs, fmt.Errorf("auto-creating schedule for domain %s / plugin %s: %w", + domain.Id, checkerName, err)) + } + } + } + } + + return errors.Join(errs...) +} diff --git a/internal/usecase/checkresult/storage.go b/internal/usecase/checkresult/storage.go new file mode 100644 index 00000000..b3297cbb --- /dev/null +++ b/internal/usecase/checkresult/storage.go @@ -0,0 +1,103 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package checkresult + +import ( + "time" + + "git.happydns.org/happyDomain/model" +) + +// CheckResultStorage defines the storage interface for check results and related data +type CheckResultStorage interface { + // Check Results + // ListCheckResults retrieves check results for a specific plugin+target combination + ListCheckResults(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, limit int) ([]*happydns.CheckResult, error) + + // ListCheckResultsByPlugin retrieves all check results for a plugin across all targets for a user + ListCheckResultsByPlugin(userId happydns.Identifier, checkName string, limit int) ([]*happydns.CheckResult, error) + + // ListCheckResultsByUser retrieves all check results for a user + ListCheckResultsByUser(userId happydns.Identifier, limit int) ([]*happydns.CheckResult, error) + + // GetCheckResult retrieves a specific check result by its ID + GetCheckResult(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, resultId happydns.Identifier) (*happydns.CheckResult, error) + + // CreateCheckResult stores a new check result + CreateCheckResult(result *happydns.CheckResult) error + + // DeleteCheckResult removes a specific check result + DeleteCheckResult(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, resultId happydns.Identifier) error + + // DeleteOldCheckResults removes old check results keeping only the most recent N results + DeleteOldCheckResults(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, keepCount int) error + + // Checker Schedules + // ListEnabledCheckerSchedules retrieves all enabled schedules (for scheduler) + ListEnabledCheckerSchedules() ([]*happydns.CheckerSchedule, error) + + // ListCheckerSchedulesByUser retrieves all schedules for a specific user + ListCheckerSchedulesByUser(userId happydns.Identifier) ([]*happydns.CheckerSchedule, error) + + // ListCheckerSchedulesByTarget retrieves all schedules for a specific target + ListCheckerSchedulesByTarget(targetType happydns.CheckScopeType, targetId happydns.Identifier) ([]*happydns.CheckerSchedule, error) + + // GetCheckerSchedule retrieves a specific schedule by ID + GetCheckerSchedule(scheduleId happydns.Identifier) (*happydns.CheckerSchedule, error) + + // CreateCheckerSchedule creates a new check schedule + CreateCheckerSchedule(schedule *happydns.CheckerSchedule) error + + // UpdateCheckerSchedule updates an existing schedule + UpdateCheckerSchedule(schedule *happydns.CheckerSchedule) error + + // DeleteCheckerSchedule removes a schedule + DeleteCheckerSchedule(scheduleId happydns.Identifier) error + + // Check Executions + // ListActiveCheckExecutions retrieves all executions that are pending or running + ListActiveCheckExecutions() ([]*happydns.CheckExecution, error) + + // GetCheckExecution retrieves a specific execution by ID + GetCheckExecution(executionId happydns.Identifier) (*happydns.CheckExecution, error) + + // CreateCheckExecution creates a new check execution record + CreateCheckExecution(execution *happydns.CheckExecution) error + + // UpdateCheckExecution updates an existing execution record + UpdateCheckExecution(execution *happydns.CheckExecution) error + + // DeleteCheckExecution removes an execution record + DeleteCheckExecution(executionId happydns.Identifier) error + + // Scheduler State + // CheckSchedulerRun marks that the scheduler has run at current time + CheckSchedulerRun() error + + // LastCheckSchedulerRun retrieves the last time the scheduler ran + LastCheckSchedulerRun() (*time.Time, error) +} + +// DomainLister provides access to domain listings for schedule discovery. +type DomainLister interface { + ListAllDomains() (happydns.Iterator[happydns.Domain], error) +} diff --git a/model/check_result.go b/model/check_result.go new file mode 100644 index 00000000..edab451b --- /dev/null +++ b/model/check_result.go @@ -0,0 +1,195 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package happydns + +import ( + "time" +) + +// CheckScopeType represents the scope level at which a check is performed +type CheckScopeType int + +const ( + CheckScopeInstance CheckScopeType = iota + CheckScopeUser + CheckScopeDomain + CheckScopeService + CheckScopeOnDemand +) + +// String returns a string representation of the check scope type +func (t CheckScopeType) String() string { + switch t { + case CheckScopeInstance: + return "instance" + case CheckScopeUser: + return "user" + case CheckScopeDomain: + return "domain" + case CheckScopeService: + return "service" + case CheckScopeOnDemand: + return "ondemand" + default: + return "unknown" + } +} + +// CheckExecutionStatus represents the current state of a check execution +type CheckExecutionStatus int + +const ( + CheckExecutionPending CheckExecutionStatus = iota + CheckExecutionRunning + CheckExecutionCompleted + CheckExecutionFailed +) + +// String returns a string representation of the check execution status +func (t CheckExecutionStatus) String() string { + switch t { + case CheckExecutionPending: + return "pending" + case CheckExecutionRunning: + return "running" + case CheckExecutionCompleted: + return "completed" + case CheckExecutionFailed: + return "failed" + default: + return "unknown" + } +} + +// CheckResult stores the result of a check execution +type CheckResult struct { + // Id is the unique identifier for this check result + Id Identifier `json:"id" swaggertype:"string"` + + // CheckerName identifies which checker was executed + CheckerName string `json:"checker_name"` + + // CheckType indicates the scope level of the check + CheckType CheckScopeType `json:"check_type"` + + // TargetId is the identifier of the target (User/Domain/Service) + TargetId Identifier `json:"target_id" swaggertype:"string"` + + // OwnerId is the owner of the check + OwnerId Identifier `json:"owner_id" swaggertype:"string"` + + // ExecutedAt is when the check was executed + ExecutedAt time.Time `json:"executed_at"` + + // ScheduledCheck indicates if this was a scheduled (true) or on-demand (false) check + ScheduledCheck bool `json:"scheduled_check"` + + // Options contains the merged checker configuration used for this check + Options CheckerOptions `json:"options,omitempty"` + + // Status is the overall check result status + Status CheckResultStatus `json:"status"` + + // StatusLine is a summary message of the check result + StatusLine string `json:"status_line"` + + // Report contains the full check report (checker-specific structure) + Report any `json:"report,omitempty"` + + // Duration is how long the check took to execute + Duration time.Duration `json:"duration" swaggertype:"integer"` + + // Error contains any error message if the execution failed + Error string `json:"error,omitempty"` +} + +// CheckExecution tracks an in-progress or completed check execution +type CheckExecution struct { + // Id is the unique identifier for this execution + Id Identifier `json:"id" swaggertype:"string"` + + // ScheduleId is the schedule that triggered this execution (nil for on-demand) + ScheduleId *Identifier `json:"schedule_id,omitempty" swaggertype:"string"` + + // CheckerName identifies which checker is being executed + CheckerName string `json:"checker_name"` + + // OwnerId is the owner of the check + OwnerId Identifier `json:"owner_id" swaggertype:"string"` + + // TargetType indicates the scope level of the check + TargetType CheckScopeType `json:"target_type"` + + // TargetId is the identifier of the target being checked + TargetId Identifier `json:"target_id" swaggertype:"string"` + + // Status is the current execution status + Status CheckExecutionStatus `json:"status"` + + // StartedAt is when the execution began + StartedAt time.Time `json:"started_at"` + + // CompletedAt is when the execution finished (nil if still running) + CompletedAt *time.Time `json:"completed_at,omitempty"` + + // ResultId links to the CheckResult (nil if execution not completed) + ResultId *Identifier `json:"result_id,omitempty" swaggertype:"string"` + + // Options contains the checker configuration for this execution + Options CheckerOptions `json:"options,omitempty"` +} + +// CheckResultUsecase defines business logic for check results +type CheckResultUsecase interface { + // ListCheckResultsByTarget retrieves check results for a specific target + ListCheckResultsByTarget(checkerName string, targetType CheckScopeType, targetId Identifier, limit int) ([]*CheckResult, error) + + // ListAllCheckResultsByTarget retrieves all check results for a target across all checkers + ListAllCheckResultsByTarget(targetType CheckScopeType, targetId Identifier, userId Identifier, limit int) ([]*CheckResult, error) + + // GetCheckResult retrieves a specific check result + GetCheckResult(checkName string, targetType CheckScopeType, targetId Identifier, resultId Identifier) (*CheckResult, error) + + // CreateCheckResult stores a new check result and enforces retention policy + CreateCheckResult(result *CheckResult) error + + // DeleteCheckResult removes a specific check result + DeleteCheckResult(checkName string, targetType CheckScopeType, targetId Identifier, resultId Identifier) error + + // DeleteAllCheckResults removes all results for a specific checker+target combination + DeleteAllCheckResults(checkName string, targetType CheckScopeType, targetId Identifier) error + + // GetCheckExecution retrieves the status of a check execution + GetCheckExecution(executionId Identifier) (*CheckExecution, error) + + // CreateCheckExecution creates a new check execution record + CreateCheckExecution(execution *CheckExecution) error + + // UpdateCheckExecution updates an existing check execution + UpdateCheckExecution(execution *CheckExecution) error + + // CompleteCheckExecution marks an execution as completed with a result + CompleteCheckExecution(executionId Identifier, resultId Identifier) error + + // FailCheckExecution marks an execution as failed + FailCheckExecution(executionId Identifier, errorMsg string) error +} diff --git a/model/check_scheduler.go b/model/check_scheduler.go new file mode 100644 index 00000000..66d86699 --- /dev/null +++ b/model/check_scheduler.go @@ -0,0 +1,140 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package happydns + +import ( + "time" +) + +// SchedulerUsecase defines the interface for triggering on-demand checks +type SchedulerUsecase interface { + Run() + Close() + TriggerOnDemandCheck(checkerName string, targetType CheckScopeType, targetID Identifier, userID Identifier, options CheckerOptions) (Identifier, error) + GetSchedulerStatus() SchedulerStatus + SetEnabled(enabled bool) error + RescheduleUpcomingChecks() (int, error) +} + +// CheckerSchedule defines a recurring check schedule +type CheckerSchedule struct { + // Id is the unique identifier for this schedule + Id Identifier `json:"id" swaggertype:"string"` + + // CheckerName identifies which checker to execute + CheckerName string `json:"checker_name"` + + // OwnerId is the owner of the schedule + OwnerId Identifier `json:"owner_id" swaggertype:"string"` + + // TargetType indicates what type of target to check + TargetType CheckScopeType `json:"target_type"` + + // TargetId is the identifier of the target to check + TargetId Identifier `json:"target_id" swaggertype:"string"` + + // Interval is how often to run the check + Interval time.Duration `json:"interval" swaggertype:"integer"` + + // Enabled indicates if the schedule is active + Enabled bool `json:"enabled"` + + // LastRun is when the check was last executed (nil if never run) + LastRun *time.Time `json:"last_run,omitempty"` + + // NextRun is when the check should next be executed + NextRun time.Time `json:"next_run"` + + // Options contains checker-specific configuration + Options CheckerOptions `json:"options,omitempty"` +} + +// SchedulerStatus holds a snapshot of the scheduler state for monitoring +type SchedulerStatus struct { + // ConfigEnabled indicates if the scheduler is enabled in the configuration file + ConfigEnabled bool `json:"config_enabled"` + + // RuntimeEnabled indicates if the scheduler is currently enabled at runtime + RuntimeEnabled bool `json:"runtime_enabled"` + + // Running indicates if the scheduler goroutine is currently running + Running bool `json:"running"` + + // WorkerCount is the number of worker goroutines + WorkerCount int `json:"worker_count"` + + // QueueSize is the number of items currently waiting in the execution queue + QueueSize int `json:"queue_size"` + + // ActiveCount is the number of checks currently being executed + ActiveCount int `json:"active_count"` + + // NextSchedules contains the upcoming scheduled checks sorted by next run time + NextSchedules []*CheckerSchedule `json:"next_schedules"` +} + +// CheckerScheduleUsecase defines business logic for check schedules +type CheckerScheduleUsecase interface { + // ListUserSchedules retrieves all schedules for a specific user + ListUserSchedules(userId Identifier) ([]*CheckerSchedule, error) + + // ListSchedulesByTarget retrieves all schedules for a specific target + ListSchedulesByTarget(targetType CheckScopeType, targetId Identifier) ([]*CheckerSchedule, error) + + // GetSchedule retrieves a specific schedule by ID + GetSchedule(scheduleId Identifier) (*CheckerSchedule, error) + + // CreateSchedule creates a new check schedule with validation + CreateSchedule(schedule *CheckerSchedule) error + + // UpdateSchedule updates an existing schedule + UpdateSchedule(schedule *CheckerSchedule) error + + // DeleteSchedule removes a schedule + DeleteSchedule(scheduleId Identifier) error + + // EnableSchedule enables a schedule + EnableSchedule(scheduleId Identifier) error + + // DisableSchedule disables a schedule + DisableSchedule(scheduleId Identifier) error + + // UpdateScheduleAfterRun updates a schedule after it has been executed + UpdateScheduleAfterRun(scheduleId Identifier) error + + // ListDueSchedules retrieves all enabled schedules that are due to run + ListDueSchedules() ([]*CheckerSchedule, error) + + // ValidateScheduleOwnership checks if a user owns a schedule + ValidateScheduleOwnership(scheduleId Identifier, ownerId Identifier) error + + // DeleteSchedulesForTarget removes all schedules for a target + DeleteSchedulesForTarget(targetType CheckScopeType, targetId Identifier) error + + // RescheduleUpcomingChecks randomizes next run times for all enabled schedules + // within their respective intervals to spread load evenly. + RescheduleUpcomingChecks() (int, error) + + // RescheduleOverdueTests reschedules overdue tests to run soon, spread over a + // short window to avoid scheduler famine after a suspend or server restart. + RescheduleOverdueChecks() (int, error) +} diff --git a/model/checker.go b/model/checker.go new file mode 100644 index 00000000..c4580310 --- /dev/null +++ b/model/checker.go @@ -0,0 +1,113 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package happydns + +// Auto-fill variable identifiers for checker option fields. +const ( + // AutoFillDomainName fills the option with the fully qualified domain name + // of the domain being tested (e.g. "example.com."). + AutoFillDomainName = "domain_name" + + // AutoFillSubdomain fills the option with the subdomain relative to the zone + // (e.g. "www" for "www.example.com." in zone "example.com."). Only + // applicable for service-scoped tests. + AutoFillSubdomain = "subdomain" + + // AutoFillServiceType fills the option with the service type identifier + // (e.g. "abstract.MatrixIM"). Only applicable for service-scoped tests. + AutoFillServiceType = "service_type" +) + +const ( + CheckResultStatusKO CheckResultStatus = iota + CheckResultStatusWarn + CheckResultStatusInfo + CheckResultStatusOK +) + +type CheckResultStatus int + +type CheckerOptions map[string]any + +type Checker interface { + ID() string + Name() string + Availability() CheckerAvailability + Options() CheckerOptionsDocumentation + RunCheck(options CheckerOptions, meta map[string]string) (*CheckResult, error) +} + +type CheckerResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Availability CheckerAvailability `json:"availability"` + Options CheckerOptionsDocumentation `json:"options"` +} + +type SetCheckerOptionsRequest struct { + Options CheckerOptions `json:"options"` +} + +type CheckerOptionsPositional struct { + CheckName string + UserId *Identifier + DomainId *Identifier + ServiceId *Identifier + + Options CheckerOptions +} + +type CheckerAvailability struct { + ApplyToDomain bool `json:"applyToDomain,omitempty"` + ApplyToService bool `json:"applyToService,omitempty"` + LimitToProviders []string `json:"limitToProviders,omitempty"` + LimitToServices []string `json:"limitToServices,omitempty"` +} + +type CheckerOptionsDocumentation struct { + RunOpts []CheckerOptionDocumentation `json:"runOpts,omitempty"` + ServiceOpts []CheckerOptionDocumentation `json:"serviceOpts,omitempty"` + DomainOpts []CheckerOptionDocumentation `json:"domainOpts,omitempty"` + UserOpts []CheckerOptionDocumentation `json:"userOpts,omitempty"` + AdminOpts []CheckerOptionDocumentation `json:"adminOpts,omitempty"` +} + +type CheckerOptionDocumentation Field + +// CheckerStatus represents the current status of a checker for a specific target, +// including whether it is enabled, its schedule, and the most recent result. +type CheckerStatus struct { + CheckerName string `json:"checker_name"` + Enabled bool `json:"enabled"` + Schedule *CheckerSchedule `json:"schedule,omitempty"` + LastResult *CheckResult `json:"last_result,omitempty"` +} + +type CheckerUsecase interface { + BuildMergedCheckerOptions(string, *Identifier, *Identifier, *Identifier, CheckerOptions) (CheckerOptions, error) + GetStoredCheckerOptionsNoDefault(string, *Identifier, *Identifier, *Identifier) (CheckerOptions, error) + GetChecker(string) (Checker, error) + GetCheckerOptions(string, *Identifier, *Identifier, *Identifier) (*CheckerOptions, error) + ListCheckers() (*map[string]Checker, error) + OverwriteSomeCheckerOptions(string, *Identifier, *Identifier, *Identifier, CheckerOptions) error + SetCheckerOptions(string, *Identifier, *Identifier, *Identifier, CheckerOptions) error +} diff --git a/model/config.go b/model/config.go index cf0fe667..7750be3b 100644 --- a/model/config.go +++ b/model/config.go @@ -99,6 +99,20 @@ type Options struct { // CaptchaLoginThreshold is the number of consecutive login failures before captcha is required. // 0 means always require captcha at login (when provider is configured). CaptchaLoginThreshold int + + PluginsDirectories []string + + // MaxResultsPerCheck is the maximum number of test results to keep per plugin+target combination + MaxResultsPerCheck int + + // ResultRetentionDays is how long to keep test results before cleanup + ResultRetentionDays int + + // TestWorkers is the number of concurrent test executions allowed + TestWorkers int + + // DisableScheduler disables the background test scheduler + DisableScheduler bool } // GetBaseURL returns the full url to the absolute ExternalURL, including BaseURL. diff --git a/model/errors.go b/model/errors.go index d3bb697b..719f7f25 100644 --- a/model/errors.go +++ b/model/errors.go @@ -27,15 +27,18 @@ import ( ) var ( - ErrAuthUserNotFound = errors.New("user not found") - ErrDomainNotFound = errors.New("domain not found") - ErrDomainLogNotFound = errors.New("domain log not found") - ErrProviderNotFound = errors.New("provider not found") - ErrSessionNotFound = errors.New("session not found") - ErrUserNotFound = errors.New("user not found") - ErrUserAlreadyExist = errors.New("user already exists") - ErrZoneNotFound = errors.New("zone not found") - ErrNotFound = errors.New("not found") + ErrAuthUserNotFound = errors.New("user not found") + ErrCheckExecutionNotFound = errors.New("check execution not found") + ErrCheckResultNotFound = errors.New("check result not found") + ErrCheckScheduleNotFound = errors.New("check schedule not found") + ErrDomainNotFound = errors.New("domain not found") + ErrDomainLogNotFound = errors.New("domain log not found") + ErrProviderNotFound = errors.New("provider not found") + ErrSessionNotFound = errors.New("session not found") + ErrUserNotFound = errors.New("user not found") + ErrUserAlreadyExist = errors.New("user already exists") + ErrZoneNotFound = errors.New("zone not found") + ErrNotFound = errors.New("not found") ) const TryAgainErr = "Sorry, we are currently unable to sent email validation link. Please try again later." diff --git a/model/form.go b/model/form.go index 5ab501eb..5c2fa441 100644 --- a/model/form.go +++ b/model/form.go @@ -104,6 +104,11 @@ type Field struct { // Description stores an helpfull sentence describing the field. Description string `json:"description,omitempty"` + + // AutoFill indicates the field value is automatically resolved by the + // software based on test context. When set, the value should not be + // entered by the user. + AutoFill string `json:"autoFill,omitempty"` } type FormState struct { diff --git a/model/identifier.go b/model/identifier.go index 32031d94..f8fdb094 100644 --- a/model/identifier.go +++ b/model/identifier.go @@ -27,6 +27,7 @@ import ( "encoding/base64" "encoding/gob" "errors" + "slices" ) const IDENTIFIER_LEN = 16 @@ -55,6 +56,10 @@ func (i Identifier) Equals(other Identifier) bool { return bytes.Equal(i, other) } +func (i Identifier) Compare(other Identifier) int { + return slices.Compare(i, other) +} + func (i *Identifier) String() string { return base64.RawURLEncoding.EncodeToString(*i) } diff --git a/model/usersettings.go b/model/usersettings.go index b50aa5c8..97b7a705 100644 --- a/model/usersettings.go +++ b/model/usersettings.go @@ -52,6 +52,17 @@ type UserSettings struct { // ShowRRTypes tells if we show equivalent RRTypes in interface (for advanced users). ShowRRTypes bool `json:"showrrtypes,omitempty"` + + // TestRetention overrides instance default for how long to keep test results (days) + TestRetention int `json:"test_retention,omitempty"` + + // DomainTestInterval is the default interval for domain-level tests (seconds) + // Default: 86400 (24 hours) + DomainTestInterval int64 `json:"domain_test_interval,omitempty"` + + // ServiceTestInterval is the default interval for service-level tests (seconds) + // Default: 3600 (1 hour) + ServiceTestInterval int64 `json:"service_test_interval,omitempty"` } func DefaultUserSettings() *UserSettings { diff --git a/plugins/.gitignore b/plugins/.gitignore new file mode 100644 index 00000000..f1fe8d1e --- /dev/null +++ b/plugins/.gitignore @@ -0,0 +1 @@ +*.so \ No newline at end of file diff --git a/plugins/README.md b/plugins/README.md new file mode 100644 index 00000000..f6a0ff27 --- /dev/null +++ b/plugins/README.md @@ -0,0 +1,336 @@ +# Writing a happyDomain Plugin + +happyDomain supports external **check plugins** — shared libraries (`.so` files) that run domain health checks and diagnostics. Plugins are loaded at runtime and integrate seamlessly into happyDomain's domain and service testing UI. + +## Overview + +A plugin is a Go shared library (`-buildmode=plugin`) that exports a single entry point: `NewCheckPlugin`. At startup, happyDomain scans its configured plugin directories, loads each `.so` file it finds, calls `NewCheckPlugin`, and registers the returned checker under the declared name. + +A plugin implements the `Checker` interface from `git.happydns.org/happyDomain/model`: + +```go +type Checker interface { + ID() string + Name() string + Availability() CheckerAvailability + Options() CheckerOptionsDocumentation + RunCheck(options CheckerOptions, meta map[string]string) (*CheckResult, error) +} +``` + +--- + +## Project Structure + +A minimal plugin lives in its own directory with `package main`: + +``` +myplugin/ +├── go.mod +├── Makefile +└── plugin.go (or split across multiple .go files) +``` + +### go.mod + +Your plugin must declare the same module path as its source tree and depend on the happyDomain model: + +``` +module git.happydns.org/happyDomain/plugins/myplugin + +go 1.25 + +require git.happydns.org/happyDomain v0.0.0 +replace git.happydns.org/happyDomain => ../../ +``` + +The `replace` directive points to your local happyDomain checkout, ensuring the plugin is compiled against the exact same types. + +> **Important:** A Go plugin and the host program must be built with the same Go toolchain version and the same versions of all shared dependencies. Any mismatch will cause a runtime load error. + +--- + +## The Entry Point + +Every plugin must export a `NewCheckPlugin` function with this exact signature: + +```go +package main + +import "git.happydns.org/happyDomain/model" + +func NewCheckPlugin() (string, happydns.Checker, error) { + return "myplugin", &MyPlugin{}, nil +} +``` + +The first return value is the unique registration name for the checker. You can use the constructor to perform one-time initialisation (read config files, create HTTP clients, etc.) and return an error if the plugin cannot function. + +--- + +## Implementing the Interface + +### `ID() string` + +Returns the unique string identifier for the checker. This name is used internally to look up the checker and to store its configuration. Use a short, lowercase, collision-resistant name: + +```go +func (p *MyPlugin) ID() string { + return "myplugin" +} +``` + +The value returned here should match the name returned by `NewCheckPlugin`. If two checkers claim the same ID, the second one is silently ignored and a conflict is logged. + +--- + +### `Name() string` + +Returns a human-readable display name for the checker: + +```go +func (p *MyPlugin) Name() string { + return "My Plugin" +} +``` + +--- + +### `Availability() CheckerAvailability` + +Declares where the checker applies: + +```go +func (p *MyPlugin) Availability() happydns.CheckerAvailability { + return happydns.CheckerAvailability{ + ApplyToDomain: true, + ApplyToService: false, + LimitToProviders: []string{}, // empty = all providers + LimitToServices: []string{"abstract.MatrixIM"}, + } +} +``` + +`CheckerAvailability` fields: + +| Field | Type | Description | +|---|---|---| +| `ApplyToDomain` | `bool` | Checker can be run against a whole domain | +| `ApplyToService` | `bool` | Checker can be run against a specific service | +| `LimitToProviders` | `[]string` | Restrict to certain DNS provider identifiers (empty = no restriction) | +| `LimitToServices` | `[]string` | Restrict to certain service type identifiers, e.g. `"abstract.MatrixIM"` (empty = no restriction) | + +--- + +### `Options() CheckerOptionsDocumentation` + +Declares all configurable options, grouped by **who sets them** and **at which scope**: + +```go +func (p *MyPlugin) Options() happydns.CheckerOptionsDocumentation { + return happydns.CheckerOptionsDocumentation{ + RunOpts: []happydns.CheckerOptionDocumentation{ /* per-run options */ }, + ServiceOpts: []happydns.CheckerOptionDocumentation{ /* per-service options */ }, + DomainOpts: []happydns.CheckerOptionDocumentation{ /* per-domain options */ }, + UserOpts: []happydns.CheckerOptionDocumentation{ /* per-user options */ }, + AdminOpts: []happydns.CheckerOptionDocumentation{ /* admin-only options */ }, + } +} +``` + +#### Option scopes + +| Field | Who sets it | Typical use | +|---|---|---| +| `RunOpts` | The user at test time | Test-specific parameters (e.g. domain to test) | +| `ServiceOpts` | The user, per service | Configuration scoped to a DNS service | +| `DomainOpts` | The user, per domain | Configuration scoped to a whole domain | +| `UserOpts` | The user, globally | Personal preferences (e.g. language) | +| `AdminOpts` | The instance administrator | Backend URLs, API keys shared by all users | + +Options from all scopes are **merged** before `RunCheck` is called, with more-specific scopes overriding less-specific ones. + +#### CheckerOptionDocumentation fields + +Each option is described by a `CheckerOptionDocumentation` (an alias for `Field`): + +| Field | Type | Description | +|---|---|---| +| `Id` | `string` | **Required.** Key used in `CheckerOptions` map | +| `Type` | `string` | Input type: `"string"`, `"select"`, … | +| `Label` | `string` | Human-readable label shown in the UI | +| `Placeholder` | `string` | Input placeholder text | +| `Default` | `any` | Default value pre-filled in the form | +| `Choices` | `[]string` | Available choices for `"select"` type inputs | +| `Required` | `bool` | Whether the field must be filled before running | +| `Secret` | `bool` | Marks the field as sensitive (e.g. API key) | +| `Hide` | `bool` | Hides the field from the user | +| `Textarea` | `bool` | Displays a multiline text area | +| `Description` | `string` | Help text shown below the field | +| `AutoFill` | `string` | Automatically populate the field from context (see below) | + +#### Auto-fill variables + +When a field's `AutoFill` is set, happyDomain populates it from the test context — the user does not need to fill it in: + +| Constant | Value | Filled with | +|---|---|---| +| `happydns.AutoFillDomainName` | `"domain_name"` | The FQDN of the domain under test (e.g. `"example.com."`) | +| `happydns.AutoFillSubdomain` | `"subdomain"` | Subdomain relative to the zone (service-scoped tests only) | +| `happydns.AutoFillServiceType` | `"service_type"` | Service type identifier (service-scoped tests only) | + +```go +{ + Id: "domainName", + Type: "string", + Label: "Domain name", + AutoFill: happydns.AutoFillDomainName, + Required: true, +}, +``` + +--- + +### `RunCheck(options CheckerOptions, meta map[string]string) (*CheckResult, error)` + +This is where the actual check happens. `options` is the merged map of all scoped options (keyed by option `Id`). `meta` carries additional context provided by the scheduler (currently reserved for future use). + +```go +func (p *MyPlugin) RunCheck(options happydns.CheckerOptions, meta map[string]string) (*happydns.CheckResult, error) { + domain, ok := options["domainName"].(string) + if !ok || domain == "" { + return nil, fmt.Errorf("domainName is required") + } + + // ... perform the check ... + + return &happydns.CheckResult{ + Status: happydns.CheckResultStatusOK, + StatusLine: "All good", + Report: myDetailedReport, + }, nil +} +``` + +Return a non-nil `error` for hard failures (network errors, invalid options). Return a `CheckResult` with a `KO` status for expected failures (e.g. the DNS check failed). + +#### CheckResult fields set by the plugin + +| Field | Type | Description | +|---|---|---| +| `Status` | `CheckResultStatus` | Overall result level | +| `StatusLine` | `string` | Short human-readable summary | +| `Report` | `any` | Arbitrary data (serialised to JSON and stored) | + +The remaining fields (`Id`, `CheckerName`, `ExecutedAt`, etc.) are filled in by happyDomain automatically. + +#### CheckResultStatus values (ordered worst → best) + +| Constant | Meaning | +|---|---| +| `CheckResultStatusKO` | Check failed | +| `CheckResultStatusWarn` | Check passed with warnings | +| `CheckResultStatusInfo` | Informational result | +| `CheckResultStatusOK` | Check fully passed | + +--- + +## Full Example + +The matrix federation checker plugin (`matrix/`) illustrates a real-world plugin: + +**`main.go`** — exports the entry point: + +```go +package main + +import "git.happydns.org/happyDomain/model" + +func NewCheckPlugin() (string, happydns.Checker, error) { + return "matrixim", &MatrixTester{ + TesterURI: "https://federationtester.matrix.org/api/report?server_name=%s", + }, nil +} +``` + +**`test.go`** — implements the interface on a struct: + +```go +func (p *MatrixTester) ID() string { return "matrixim" } + +func (p *MatrixTester) Name() string { return "Matrix Federation Tester" } + +func (p *MatrixTester) Availability() happydns.CheckerAvailability { + return happydns.CheckerAvailability{ + ApplyToService: true, + LimitToServices: []string{"abstract.MatrixIM"}, + } +} + +func (p *MatrixTester) Options() happydns.CheckerOptionsDocumentation { /* ... */ } + +func (p *MatrixTester) RunCheck(options happydns.CheckerOptions, meta map[string]string) (*happydns.CheckResult, error) { /* ... */ } +``` + +The built-in Zonemaster checker (`checks/zonemaster.go`) shows a more complex flow: it starts an asynchronous test, polls for completion, and aggregates results across multiple severity levels. Although it is compiled in rather than loaded as a `.so`, it implements the same `Checker` interface and is a useful reference. + +--- + +## Building + +Use `-buildmode=plugin`: + +```bash +go build -buildmode=plugin -o happydomain-plugin-test-myplugin.so \ + git.happydns.org/happyDomain/plugins/myplugin +``` + +A minimal `Makefile`: + +```makefile +PLUGIN_NAME=myplugin +TARGET=../happydomain-plugin-test-$(PLUGIN_NAME).so + +all: $(TARGET) + +$(TARGET): *.go + go build -buildmode=plugin -o $@ git.happydns.org/happyDomain/plugins/$(PLUGIN_NAME) +``` + +> **Naming convention:** happyDomain looks for any `.so` file in the plugin directory, but using the prefix `happydomain-plugin-test-` makes the purpose clear. + +--- + +## Deployment + +### 1. Copy the `.so` file to a plugin directory + +```bash +cp happydomain-plugin-test-myplugin.so /usr/lib/happydomain/plugins/ +``` + +### 2. Configure happyDomain to load that directory + +In your `happydomain.conf`: + +``` +plugins-directories=/usr/lib/happydomain/plugins +``` + +Or via an environment variable: + +```bash +HAPPYDOMAIN_PLUGINS_DIRECTORIES=/usr/lib/happydomain/plugins +``` + +Multiple directories can be specified as a comma-separated list. happyDomain scans each directory at startup and attempts to load every `.so` file it finds. Loading errors are logged but do not prevent the server from starting. + +### 3. Verify + +Check the server logs at startup for a line like: + +``` +Plugin myplugin loaded +``` + +If a name conflict or load error occurs, a warning is logged with the filename and reason. diff --git a/plugins/matrix/Makefile b/plugins/matrix/Makefile new file mode 100644 index 00000000..64c83f48 --- /dev/null +++ b/plugins/matrix/Makefile @@ -0,0 +1,7 @@ +PLUGIN_NAME=matrix +TARGET=../happydomain-plugin-test-$(PLUGIN_NAME).so + +all: $(TARGET) + +$(TARGET): *.go + go build -buildmode=plugin -o $@ git.happydns.org/happyDomain/plugins/$(PLUGIN_NAME) diff --git a/plugins/matrix/main.go b/plugins/matrix/main.go new file mode 100644 index 00000000..177776b0 --- /dev/null +++ b/plugins/matrix/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "git.happydns.org/happyDomain/model" +) + +func NewCheckPlugin() (string, happydns.Checker, error) { + return "matrixim", &MatrixTester{ + TesterURI: "https://federationtester.matrix.org/api/report?server_name=%s", + }, nil +} diff --git a/plugins/matrix/test.go b/plugins/matrix/test.go new file mode 100644 index 00000000..98288e52 --- /dev/null +++ b/plugins/matrix/test.go @@ -0,0 +1,146 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "strings" + + "git.happydns.org/happyDomain/model" +) + +type MatrixTester struct { + TesterURI string +} + +func (p *MatrixTester) ID() string { + return "matrixim" +} + +func (p *MatrixTester) Name() string { + return "Matrix Federation Tester" +} + +func (p *MatrixTester) Availability() happydns.CheckerAvailability { + return happydns.CheckerAvailability{ + ApplyToService: true, + LimitToServices: []string{"abstract.MatrixIM"}, + } +} + +func (p *MatrixTester) Options() happydns.CheckerOptionsDocumentation { + return happydns.CheckerOptionsDocumentation{ + RunOpts: []happydns.CheckerOptionDocumentation{ + { + Id: "serviceDomain", + Type: "string", + Label: "Matrix domain", + Placeholder: "matrix.org", + Default: "matrix.org", + Required: true, + }, + }, + AdminOpts: []happydns.CheckerOptionDocumentation{ + { + Id: "federationTesterServer", + Type: "string", + Label: "Federation Tester Server", + Placeholder: "https://federationtester.matrix.org/", + Default: "https://federationtester.matrix.org/", + Required: true, + }, + }, + } +} + +type FederationTesterResponse struct { + WellKnownResult struct { + Server string `json:"m.server"` + Result string `json:"result"` + } + DNSResult struct { + SRVError *struct { + Message string + } + } + ConnectionReports map[string]struct { + Errors []string + } + ConnectionErrors map[string]struct { + Message string + } + Version struct { + Name string `json:"name"` + Version string `json:"version"` + } + FederationOK bool `json:"FederationOK"` +} + +func (p *MatrixTester) RunCheck(options happydns.CheckerOptions, meta map[string]string) (*happydns.CheckResult, error) { + var domain string + + if dn, ok := options["domain"]; ok { + domain, _ = dn.(string) + } else if origin, ok := options["origin"]; ok { + domain, _ = origin.(string) + } + + if domain == "" { + return nil, fmt.Errorf("domain not defined") + } + + domain = strings.TrimSuffix(domain, ".") + + resp, err := http.Get(fmt.Sprintf(p.TesterURI, domain)) + if err != nil { + return nil, fmt.Errorf("unable to perform the test: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + return nil, fmt.Errorf("Sorry, the federation tester is broken. Check on https://federationtester.matrix.org/#%s", strings.TrimSuffix(domain, ".")) + } + + var status happydns.CheckResultStatus + var statusLine string + var federationTest FederationTesterResponse + + err = json.NewDecoder(resp.Body).Decode(&federationTest) + if err != nil { + log.Printf("Error in check_matrix_federation, when decoding json: %s", err.Error()) + return nil, fmt.Errorf("sorry, the federation tester is broken. Check on https://federationtester.matrix.org/#%s", strings.TrimSuffix(domain, ".")) + } + + if federationTest.FederationOK { + status = happydns.CheckResultStatusOK + statusLine = "Running " + federationTest.Version.Name + " " + federationTest.Version.Version + } else { + status = happydns.CheckResultStatusKO + + if federationTest.DNSResult.SRVError != nil && federationTest.WellKnownResult.Result != "" { + statusLine = fmt.Sprintf("%s OR %s", federationTest.DNSResult.SRVError.Message, federationTest.WellKnownResult.Result) + } else if len(federationTest.ConnectionErrors) > 0 { + var msg strings.Builder + for srv, cerr := range federationTest.ConnectionErrors { + if msg.Len() > 0 { + msg.WriteString("; ") + } + msg.WriteString(srv) + msg.WriteString(": ") + msg.WriteString(cerr.Message) + } + statusLine = fmt.Sprintf("Connection errors: %s", msg.String()) + } else if federationTest.WellKnownResult.Server != strings.TrimSuffix(domain, ".") { + statusLine = fmt.Sprintf("Bad homeserver_name: got %s, expected %s.", federationTest.WellKnownResult.Server, strings.TrimSuffix(domain, ".")) + } else { + statusLine = fmt.Sprintf("An unimplemented error occurs. Please report this to happydomain team. But know that federation seems to be broken. Check https://federationtester.matrix.org/#%s", strings.TrimSuffix(domain, ".")) + } + } + + return &happydns.CheckResult{ + Status: status, + StatusLine: statusLine, + Report: federationTest, + }, nil +} diff --git a/web-admin/routes.go b/web-admin/routes.go index f3ab3f32..b3e92d71 100644 --- a/web-admin/routes.go +++ b/web-admin/routes.go @@ -110,6 +110,7 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.Engine) { // Routes to virtual content router.GET("/auth_users/*_", serveOrReverse("/", cfg)) + router.GET("/checks/*_", serveOrReverse("/", cfg)) router.GET("/domains/*_", serveOrReverse("/", cfg)) router.GET("/providers/*_", serveOrReverse("/", cfg)) router.GET("/sessions/*_", serveOrReverse("/", cfg)) diff --git a/web-admin/src/routes/+layout.svelte b/web-admin/src/routes/+layout.svelte index f28ed70e..b0bc7659 100644 --- a/web-admin/src/routes/+layout.svelte +++ b/web-admin/src/routes/+layout.svelte @@ -101,6 +101,12 @@ Sessions + + Checkers + + + Scheduler + diff --git a/web-admin/src/routes/checkers/+page.svelte b/web-admin/src/routes/checkers/+page.svelte new file mode 100644 index 00000000..737f7ef5 --- /dev/null +++ b/web-admin/src/routes/checkers/+page.svelte @@ -0,0 +1,150 @@ + + + + + + + +

+ + Checkers +

+

+ Manage all checkers + {#await checkersQ then checkersR} + Total: {Object.keys(checkersR.data ?? {}).length} checkers + {/await} +

+ +
+ + + + + + + + + + + + + {#await checkersQ} + Please wait... + {:then checkersR} + {@const checkers = checkersR.data} +
+ + + + + + + + + + {#if !checkers || Object.keys(checkers).length == 0} + + + + {:else} + {#each Object.entries(checkers ?? {}).filter(([name, _info]) => name + .toLowerCase() + .indexOf(searchQuery.toLowerCase()) > -1) as [checkerName, checkerInfo]} + + + + + + {/each} + {/if} + +
Plugin NameAvailabilityActions
+ No checkers available +
{checkerInfo.name || checkerName} + {#if checkerInfo.availability} + {#if checkerInfo.availability.applyToDomain} + Domain + {/if} + {#if checkerInfo.availability.limitToProviders && checkerInfo.availability.limitToProviders.length > 0} + + Provider-specific + + {/if} + {#if checkerInfo.availability.limitToServices && checkerInfo.availability.limitToServices.length > 0} + + Service-specific + + {/if} + {:else} + General + {/if} + + + + Manage + +
+
+ {:catch error} + +

+ + Error loading checkers: {error.message} +

+
+ {/await} +
diff --git a/web-admin/src/routes/checkers/[cname]/+page.svelte b/web-admin/src/routes/checkers/[cname]/+page.svelte new file mode 100644 index 00000000..9012cd02 --- /dev/null +++ b/web-admin/src/routes/checkers/[cname]/+page.svelte @@ -0,0 +1,322 @@ + + + + + + + + +

+ + {cname} +

+ +
+ + {#await checkerStatusQ} + +

+ + Loading checker status... +

+
+ {:then status} + {#if status} + + + + + Checker Information + + +
+
Name:
+
{status.name}
+ +
Availability:
+
+ {#if status.availableOn} +
+ {#if status.availableOn.applyToDomain} + Domain-level + {/if} + {#if status.availableOn.limitToProviders && status.availableOn.limitToProviders.length > 0} + + Providers: {status.availableOn.limitToProviders.join( + ", ", + )} + + {/if} + {#if status.availableOn.limitToServices && status.availableOn.limitToServices.length > 0} + + Services: {status.availableOn.limitToServices.join( + ", ", + )} + + {/if} + {#if !status.availableOn.applyToDomain && (!status.availableOn.limitToProviders || status.availableOn.limitToProviders.length === 0) && (!status.availableOn.limitToServices || status.availableOn.limitToServices.length === 0)} + General + {/if} +
+ {:else} + General + {/if} +
+
+
+
+ + + + {#await checkerOptionsQ} + + +

+ + Loading options... +

+
+
+ {:then _optionsR} + {@const adminOpts = status.options?.adminOpts || []} + {@const readOnlyOptGroups = [ + { + key: "userOpts", + label: "User Options", + opts: status.options?.userOpts || [], + }, + { + key: "domainOpts", + label: "Domain Options", + opts: status.options?.domainOpts || [], + }, + { + key: "serviceOpts", + label: "Service Options", + opts: status.options?.serviceOpts || [], + }, + { + key: "runOpts", + label: "Run Options", + opts: status.options?.runOpts || [], + }, + ]} + {@const hasAnyOpts = + adminOpts.length > 0 || + readOnlyOptGroups.some((g) => g.opts.length > 0)} + {@const orphanedOpts = getOrphanedOptions(adminOpts)} + + {#if orphanedOpts.length > 0} + +
+
+ + Orphaned options detected: + {orphanedOpts.join(", ")} +
+ +
+
+ {/if} + + {#if adminOpts.length > 0} + + + Admin Options + + +
+ {#each adminOpts as optDoc} + {#if optDoc.id} + {@const optName = optDoc.id} + + + + {/if} + {/each} +
+ +
+
+
+
+ {/if} + + + + {#if !hasAnyOpts} + + + + + This checker has no configurable options. + + + + {/if} + {:catch error} + + + + + Error loading options: {error.message} + + + + {/await} + +
+ {:else} + + + Error: checker data not found + + {/if} + {:catch error} + + + Error loading checker: {error.message} + + {/await} +
diff --git a/web-admin/src/routes/scheduler/+page.svelte b/web-admin/src/routes/scheduler/+page.svelte new file mode 100644 index 00000000..68414645 --- /dev/null +++ b/web-admin/src/routes/scheduler/+page.svelte @@ -0,0 +1,341 @@ + + + + + + + +

+ + Test Scheduler +

+

Monitor and control the background test scheduler

+ +
+ + {#if loading} +
+ + Loading scheduler status... +
+ {:else if error} + + + Error loading scheduler status: {error} + + + {:else if status} + + + +
+ Scheduler Status + +
+
+ + + +
Config Enabled
+ {#if status.config_enabled} + Yes + {:else} + No + {/if} + + +
Runtime Enabled
+ {#if status.runtime_enabled} + Yes + {:else} + Disabled + {/if} + + +
Running
+ {#if status.running} + Running + {:else} + Stopped + {/if} + + +
Workers
+ {status.worker_count} + + +
Queue Size
+ {status.queue_size} + + +
Active Executions
+ {status.active_count} + +
+ + {#if status.config_enabled} +
+ {#if status.runtime_enabled} + + {:else} + + {/if} + +
+ {:else} +

+ + The scheduler is disabled in the server configuration and cannot be enabled at + runtime. +

+ {/if} +
+
+ + + + + + Upcoming Scheduled Checks + {#if status.next_schedules} + {status.next_schedules.length} + {/if} + + +
+ + + + + + + + + + + + + {#if !status.next_schedules || status.next_schedules.length === 0} + + + + {:else} + {#each status.next_schedules as schedule} + + + + + + + + + {/each} + {/if} + +
PluginTarget TypeTarget IDIntervalLast RunNext Run
+ No scheduled checks +
{schedule.checker_name}{targetTypeName(schedule.target_type)}{schedule.target_id}{formatDuration(schedule.interval)} + {#if schedule.last_run} + {new Date(schedule.last_run).toLocaleString()} + {:else} + Never + {/if} + + {#if new Date(schedule.next_run) < new Date()} + + + {new Date(schedule.next_run).toLocaleString()} + + {:else} + {new Date(schedule.next_run).toLocaleString()} + {/if} +
+
+
+
+ {/if} +
diff --git a/web/routes.go b/web/routes.go index 6677e103..8c634523 100644 --- a/web/routes.go +++ b/web/routes.go @@ -131,6 +131,7 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, captchaVerifi router.GET("/service-worker.js", serveFile) // Routes to virtual content + router.GET("/checks/*_", serveIndex) router.GET("/domains/*_", serveIndex) router.GET("/email-validation", serveIndex) router.GET("/forgotten-password", serveIndex) diff --git a/web/src/lib/api/checks.ts b/web/src/lib/api/checks.ts new file mode 100644 index 00000000..7c717bb8 --- /dev/null +++ b/web/src/lib/api/checks.ts @@ -0,0 +1,237 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2022-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { + getChecks, + getChecksByCid, + getChecksByCidOptions, + postChecksByCidOptions, + putChecksByCidOptions, + getChecksByCidOptionsByOptname, + putChecksByCidOptionsByOptname, + postDomainsByDomainChecksByCname, + getDomainsByDomainChecksByCnameOptions, + getDomainsByDomainChecks, + getDomainsByDomainChecksByCnameResults, + getDomainsByDomainChecksByCnameResultsByResultId, + deleteDomainsByDomainChecksByCnameResults, + deleteDomainsByDomainChecksByCnameResultsByResultId, + getDomainsByDomainChecksByCnameExecutionsByExecutionId, + postPluginsTestsSchedules, + putPluginsTestsSchedulesByScheduleId, +} from "$lib/api-base/sdk.gen"; +import { unwrapSdkResponse } from "./errors"; +import type { + CheckerList, + CheckerInfo, + CheckerOptions, + AvailableCheck, + CheckerSchedule, + CheckResult, + CheckExecution, +} from "$lib/model/check"; +import { CheckScopeType } from "$lib/model/check"; + +export async function listChecks(): Promise { + return unwrapSdkResponse(await getChecks()) as CheckerList; +} + +export async function getCheckStatus(checkId: string): Promise { + return unwrapSdkResponse( + await getChecksByCid({ + path: { cid: checkId }, + }), + ) as unknown as CheckerInfo; +} + +export async function getCheckOptions(checkId: string): Promise { + return unwrapSdkResponse( + await getChecksByCidOptions({ + path: { cid: checkId }, + }), + ) as CheckerOptions; +} + +export async function addCheckOptions(checkId: string, options: CheckerOptions): Promise { + return unwrapSdkResponse( + await postChecksByCidOptions({ + path: { cid: checkId }, + body: { options } as any, + }), + ) as boolean; +} + +export async function updateCheckOptions(checkId: string, options: CheckerOptions): Promise { + return unwrapSdkResponse( + await putChecksByCidOptions({ + path: { cid: checkId }, + body: { options } as any, + }), + ) as boolean; +} + +export async function getCheckOption(checkId: string, optionName: string): Promise { + return unwrapSdkResponse( + await getChecksByCidOptionsByOptname({ + path: { cid: checkId, optname: optionName }, + }), + ); +} + +export async function setcheckOption( + checkId: string, + optionName: string, + value: any, +): Promise { + return unwrapSdkResponse( + await putChecksByCidOptionsByOptname({ + path: { cid: checkId, optname: optionName }, + body: value as any, + }), + ) as boolean; +} + +export async function getDomainCheckOptions(domainId: string, checkId: string): Promise { + return unwrapSdkResponse( + await getDomainsByDomainChecksByCnameOptions({ + path: { domain: domainId, cname: checkId }, + }), + ) as CheckerOptions; +} + +export async function triggerCheck( + domainId: string, + checkId: string, + options?: CheckerOptions, +): Promise<{ execution_id?: string }> { + return unwrapSdkResponse( + await postDomainsByDomainChecksByCname({ + path: { domain: domainId, cname: checkId }, + body: { options } as any, + }), + ) as { execution_id?: string }; +} + +export async function listAvailableChecks(domainId: string): Promise { + return unwrapSdkResponse( + await getDomainsByDomainChecks({ + path: { domain: domainId }, + }), + ) as unknown as AvailableCheck[]; +} + +export async function createCheckSchedule(data: { + checker_name: string; + target_type: CheckScopeType; + target_id: string; + interval: number; + enabled: boolean; + options?: CheckerOptions; +}): Promise { + return unwrapSdkResponse( + await postPluginsTestsSchedules({ + body: { + checker_name: data.checker_name, + target_type: data.target_type, + target_id: data.target_id, + interval: data.interval, + enabled: data.enabled, + options: data.options, + }, + }), + ) as CheckerSchedule; +} + +export async function updateCheckSchedule( + scheduleId: string, + schedule: CheckerSchedule, +): Promise { + return unwrapSdkResponse( + await putPluginsTestsSchedulesByScheduleId({ + path: { schedule_id: scheduleId }, + body: { + id: schedule.id, + checker_name: schedule.checker_name, + target_type: schedule.target_type, + target_id: schedule.target_id, + interval: schedule.interval, + enabled: schedule.enabled, + last_run: schedule.last_run, + next_run: schedule.next_run, + options: schedule.options, + }, + }), + ) as CheckerSchedule; +} + +export async function getCheckResult( + domainId: string, + checkName: string, + resultId: string, +): Promise { + return unwrapSdkResponse( + await getDomainsByDomainChecksByCnameResultsByResultId({ + path: { domain: domainId, cname: checkName, result_id: resultId }, + }), + ) as unknown as CheckResult; +} + +export async function deleteCheckResult( + domainId: string, + checkName: string, + resultId: string, +): Promise { + await deleteDomainsByDomainChecksByCnameResultsByResultId({ + path: { domain: domainId, cname: checkName, result_id: resultId }, + }); +} + +export async function listCheckResults( + domainId: string, + checkName: string, +): Promise { + return unwrapSdkResponse( + await getDomainsByDomainChecksByCnameResults({ + path: { domain: domainId, cname: checkName }, + }), + ) as unknown as CheckResult[]; +} + +export async function deleteAllCheckResults( + domainId: string, + checkName: string, +): Promise { + await deleteDomainsByDomainChecksByCnameResults({ + path: { domain: domainId, cname: checkName }, + }); +} + +export async function getCheckExecution( + domainId: string, + checkName: string, + executionId: string, +): Promise { + return unwrapSdkResponse( + await getDomainsByDomainChecksByCnameExecutionsByExecutionId({ + path: { domain: domainId, cname: checkName, execution_id: executionId }, + }), + ) as unknown as CheckExecution; +} diff --git a/web/src/lib/components/Header.svelte b/web/src/lib/components/Header.svelte index b047d02c..da2efac7 100644 --- a/web/src/lib/components/Header.svelte +++ b/web/src/lib/components/Header.svelte @@ -183,6 +183,13 @@ > {$t("menu.dns-resolver")} + + {$t("menu.checkers")} + {$t("menu.my-account")} diff --git a/web/src/lib/components/VoxPeople.svelte b/web/src/lib/components/VoxPeople.svelte index c556100f..01f8d5c8 100644 --- a/web/src/lib/components/VoxPeople.svelte +++ b/web/src/lib/components/VoxPeople.svelte @@ -23,6 +23,7 @@ + +{#each groups as optGroup} + {#if optGroup.opts.length > 0} + + + {optGroup.label} + {$t("checks.detail.read-only")} + + +
+ {#each optGroup.opts as optDoc} + {@const optName = optDoc.id!} +
+ {optDoc.label || optDoc.id}: +
+
+ {#if optDoc.autoFill} + + {getAutoFillLabel(optDoc.autoFill)} + + {:else if optDoc.default} + {optDoc.default} + {:else if optDoc.placeholder} + {optDoc.placeholder} + {/if} + {#if optDoc.description} + {optDoc.description} + {/if} + + {$t("checks.option-groups.type", { + type: optDoc.type || "string", + })} + + {#if optDoc.required && !optDoc.autoFill} + + {$t("checks.option-groups.required")} + + {/if} +
+ {/each} +
+
+
+ {/if} +{/each} diff --git a/web/src/lib/components/modals/RunCheckModal.svelte b/web/src/lib/components/modals/RunCheckModal.svelte new file mode 100644 index 00000000..2685b532 --- /dev/null +++ b/web/src/lib/components/modals/RunCheckModal.svelte @@ -0,0 +1,204 @@ + + + + + + + {$t("checks.run-check.title")}: {checkDisplayName} + + + {#if checkStatusPromise && domainOptionsPromise} + {#await Promise.all([checkStatusPromise, domainOptionsPromise])} +
+ +

{$t("checks.run-check.loading-options")}

+
+ {:then [status, _domainOpts]} + {@const runOpts = status.options?.runOpts || []} + {#if runOpts.length > 0} +

+ {$t("checks.run-check.configure-info")} +

+
{ + e.preventDefault(); + handleRunCheck(); + }} + > + {#each runOpts as optDoc} + {#if optDoc.id} + {@const optName = optDoc.id} + + + + {/if} + {/each} + {@const otherOpts = [ + ...(status.options?.adminOpts || []), + ...(status.options?.userOpts || []), + ...(status.options?.domainOpts || []), + ...(status.options?.serviceOpts || []), + ].filter((o) => o.id)} + {#if otherOpts.length > 0} + + {#if showAdvanced} + {#each otherOpts as optDoc} + {@const optName = optDoc.id} + + + + {/each} + {/if} + {/if} +
+ {:else} + + + {$t("checks.run-check.no-options")} + + {/if} + {:catch error} + + + {$t("checks.run-check.error-loading-options", { error: error.message })} + + {/await} + {/if} +
+ + + + +
diff --git a/web/src/lib/locales/en.json b/web/src/lib/locales/en.json index d683e97a..d9f3e904 100644 --- a/web/src/lib/locales/en.json +++ b/web/src/lib/locales/en.json @@ -82,6 +82,7 @@ "share": "Share the zone…", "upload": "Import a zone file", "view": "View my zone", + "view-checks": "View checks", "others": "More actions on {{domain}}" }, "alert": { @@ -238,6 +239,7 @@ "my-domains": "My domains", "my-providers": "My domain providers", "dns-resolver": "DNS resolver", + "checkers": "Domain Checkers", "my-account": "My account", "logout": "Sign out", "provider-features": "Supported providers", @@ -538,10 +540,197 @@ "ttl": "Remaining time in cache", "showDNSSEC": "Show DNSSEC records in answer (if any)" }, + "checks": { + "run-test": { + "title": "Run Test", + "loading-options": "Loading checker options...", + "configure-info": "Configure test options below. Pre-filled values are from domain-level settings.", + "no-options": "This test has no configurable options. Click \"Run Test\" to execute with default settings.", + "error-loading-options": "Error loading checker options: {{error}}", + "run-button": "Run Test", + "triggered-success": "Test triggered successfully! Execution ID: {{id}}", + "trigger-failed": "Failed to trigger test: {{error}}", + "advanced-options": "Advanced options" + }, + "never": "Never", + "na": "N/A", + "relative": { + "in-less-than-a-minute": "in less than a minute", + "just-now": "just now", + "in": "in {{label}}", + "ago": "{{label}} ago" + }, + "status": { + "ok": "OK", + "info": "Info", + "warning": "Warning", + "error": "Error", + "unknown": "Unknown", + "pending": "Pending", + "not-run": "Not run" + }, + "list": { + "title": "Checks for ", + "loading": "Loading checkers...", + "loading-checkers": "Loading checker information...", + "no-checks": "No checks available for this domain.", + "run-test": "Run Test", + "view-results": "View Results", + "error-loading": "Error loading checkers: {{error}}", + "unknown-version": "Unknown", + "table": { + "checker": "Checker", + "status": "Status", + "last-run": "Last Run", + "schedule": "Schedule", + "actions": "Actions" + }, + "schedule": { + "enabled": "Enabled", + "disabled": "Disabled" + } + }, + "schedule": { + "title": "Schedule", + "card-title": "Automatic scheduling", + "auto-enabled": "Run automatically", + "auto-disabled": "Disabled (run manually only)", + "interval-label": "Check interval", + "hours": "hours", + "interval-hint": "Minimum 1 hour. The test will run once per interval.", + "next-run": "Next scheduled run", + "last-run": "Last run", + "no-schedule-yet": "No schedule created yet. Save to create one.", + "save": "Save", + "save-failed": "Failed to save schedule", + "saved": "Schedule saved successfully." + }, + "results": { + "loading": "Loading check results...", + "no-results": "No test results yet. Click \"Run Test Now\" to execute the test.", + "title": "Test Results ({{count}})", + "run-test-now": "Run Test Now", + "back-to-checks": "Back to checks", + "delete-all": "Delete All", + "delete-confirm": "Are you sure you want to delete this test result?", + "delete-all-confirm": "Are you sure you want to delete ALL test results for this test? This cannot be undone.", + "delete-failed": "Failed to delete result", + "delete-all-failed": "Failed to delete results", + "configure": "Configure", + "domain-level": "Domain-level", + "error-loading": "Error loading check results: {{error}}", + "table": { + "executed-at": "Executed At", + "status": "Status", + "message": "Message", + "duration": "Duration", + "type": "Type", + "actions": "Actions" + }, + "type": { + "scheduled": "Scheduled", + "manual": "Manual" + }, + "pending": { + "queued": "Queued, waiting to run…", + "running": "Running…" + }, + "view": "View" + }, + "result": { + "title": "Test Result Details", + "loading": "Loading check result...", + "relaunch": "Relaunch Test", + "delete": "Delete Result", + "relaunch-failed": "Failed to relaunch test", + "delete-confirm": "Are you sure you want to delete this test result?", + "delete-failed": "Failed to delete result", + "error-loading": "Error loading check result: {{error}}", + "milliseconds": "milliseconds", + "seconds": "seconds", + "type": { + "scheduled": "Scheduled Test", + "manual": "Manual Test" + }, + "test-options": "Test Options", + "full-report": "Full Report", + "field": { + "domain": "Domain:", + "executed-at": "Executed At:", + "duration": "Duration:", + "status": "Status:", + "status-message": "Status Message:", + "error": "Error:" + } + }, + "title": "Domain Checkers", + "description": "Configure automated checks for your domains", + "available-count": "Available: {{count}} checks", + "search-placeholder": "Search checks...", + "loading": "Loading checkers...", + "loading-info": "Loading checker information...", + "no-checks": "No checks available", + "error-loading": "Error loading checkers: {{error}}", + "error-loading-test": "Error loading checker: {{error}}", + "test-info-not-found": "Error: Checker information not found", + "back-to-checks": "Back to checks", + "table": { + "name": "Test Name", + "availability": "Availability", + "actions": "Actions" + }, + "availability": { + "domain": "Domain", + "provider-specific": "Provider-specific", + "service-specific": "Service-specific", + "general": "General", + "domain-level": "Domain-level", + "providers": "Providers: {{providers}}", + "services": "Services: {{services}}" + }, + "actions": { + "configure": "Configure" + }, + "detail": { + "test-information": "Test Information", + "name": "Name:", + "availability": "Availability:", + "loading-options": "Loading options...", + "configuration": "Configuration", + "save-changes": "Save Changes", + "no-configurable-options": "This test has no configurable options.", + "error-loading-options": "Error loading options: {{error}}", + "orphaned-options": "Orphaned options detected: {{options}}", + "clean-up": "Clean Up", + "read-only": "(Read-only)" + }, + "option-groups": { + "global-settings": "Global Settings", + "domain-settings": "Domain-specific Settings", + "service-settings": "Service-specific Settings", + "test-parameters": "Test Parameters", + "type": "Type: {{type}}", + "required": "Required" + }, + "auto-fill": { + "domain_name": "auto-filled: domain name", + "subdomain": "auto-filled: subdomain", + "service_type": "auto-filled: service type", + "generic": "auto-filled: {{key}}" + }, + "messages": { + "options-updated": "Checker options updated successfully", + "options-cleaned": "Orphaned options removed successfully", + "update-failed": "Failed to update options: {{error}}", + "clean-failed": "Failed to clean options: {{error}}" + } + }, "zones": { "upload": "Import a zone", "import-text": "Import from text", "import-file": "Import from file", - "return-to": "Go to the zone" + "return-to": "Go to the zone", + "return-to-results": "Back to results", + "return-to-checks": "Back to checks" } } diff --git a/web/src/lib/model/check.ts b/web/src/lib/model/check.ts new file mode 100644 index 00000000..51f398da --- /dev/null +++ b/web/src/lib/model/check.ts @@ -0,0 +1,76 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2022-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import type { + HappydnsCheckerAvailability, + HappydnsCheckerOptionDocumentation, + HappydnsCheckerOptionsDocumentation, + HappydnsCheckerOptions, + HappydnsCheckerResponse, + HappydnsCheckerSchedule, + HappydnsCheckResult, + HappydnsCheckExecution, +} from "$lib/api-base/types.gen"; + +// Re-export auto-generated types with better names +export type CheckerAvailability = HappydnsCheckerAvailability; +export type CheckerInfo = HappydnsCheckerResponse; +export type CheckerList = { [key: string]: HappydnsCheckerResponse }; +export type CheckerOptions = HappydnsCheckerOptions; +export type CheckerOptionsDocumentation = HappydnsCheckerOptionsDocumentation; +export type CheckerSchedule = HappydnsCheckerSchedule; +export type CheckResult = HappydnsCheckResult; +export type CheckExecution = HappydnsCheckExecution; + +// Make 'id' required for CheckerOptionDocumentation +export interface CheckerOptionDocumentation extends Omit { + id: string; +} + +// Enums for named access to numeric status/scope values +export enum CheckResultStatus { + KO = 0, + Warn = 1, + Info = 2, + OK = 3, +} + +export enum CheckScopeType { + CheckScopeInstance = 0, + CheckScopeUser = 1, + CheckScopeDomain = 2, + CheckScopeService = 3, + CheckScopeOnDemand = 4, +} + +export enum CheckExecutionStatus { + CheckExecutionPending = 0, + CheckExecutionRunning = 1, + CheckExecutionCompleted = 2, + CheckExecutionFailed = 3, +} + +export interface AvailableCheck { + checker_name: string; + enabled: boolean; + schedule?: CheckerSchedule; + last_result?: CheckResult; +} diff --git a/web/src/lib/model/custom_form.svelte.ts b/web/src/lib/model/custom_form.svelte.ts index 5626e37d..e84e57ca 100644 --- a/web/src/lib/model/custom_form.svelte.ts +++ b/web/src/lib/model/custom_form.svelte.ts @@ -31,6 +31,7 @@ export class Field { required? = $state(); secret? = $state(); textarea? = $state(); + autoFill? = $state(); } export class CustomForm { diff --git a/web/src/lib/model/test.ts b/web/src/lib/model/test.ts new file mode 100644 index 00000000..67c325d9 --- /dev/null +++ b/web/src/lib/model/test.ts @@ -0,0 +1,106 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2022-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import type { CheckerOptions } from "./check"; + +export enum TestScopeType { + TestScopeInstance = 0, + TestScopeUser = 1, + TestScopeDomain = 2, + TestScopeZone = 3, + TestScopeService = 4, + TestScopeOnDemand = 5, +} + +export enum TestExecutionStatus { + TestExecutionPending = 0, + TestExecutionRunning = 1, + TestExecutionCompleted = 2, + TestExecutionFailed = 3, +} + +export enum PluginResultStatus { + KO = 0, + Warn = 1, + Info = 2, + OK = 3, +} + +export interface TestResult { + id: string; + plugin_name: string; + test_type: TestScopeType; + target_id: string; + user_id: string; + executed_at: string; + scheduled_test: boolean; + options?: CheckerOptions; + status: PluginResultStatus; + status_line: string; + report?: any; + duration?: number; + error?: string; +} + +export interface TestSchedule { + id: string; + plugin_name: string; + user_id: string; + target_type: TestScopeType; + target_id: string; + interval: number; + enabled: boolean; + last_run?: string; + next_run: string; + options?: CheckerOptions; +} + +export interface TestExecution { + id: string; + schedule_id?: string; + plugin_name: string; + user_id: string; + target_id: string; + status: TestExecutionStatus; + started_at: string; + completed_at?: string; + result_id?: string; +} + +export interface AvailableTest { + plugin_name: string; + enabled: boolean; + schedule?: TestSchedule; + last_result?: TestResult; +} + +export interface TriggerTestRequest { + options?: CheckerOptions; +} + +export interface CreateScheduleRequest { + plugin_name: string; + target_type: TestScopeType; + target_id: string; + interval: number; + enabled: boolean; + options?: CheckerOptions; +} diff --git a/web/src/lib/stores/checks.ts b/web/src/lib/stores/checks.ts new file mode 100644 index 00000000..3574c119 --- /dev/null +++ b/web/src/lib/stores/checks.ts @@ -0,0 +1,32 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2022-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { listChecks } from "$lib/api/checks"; +import type { CheckerList } from "$lib/model/check"; +import { writable, type Writable } from "svelte/store"; + +export const checks: Writable = writable(undefined); + +export async function refreshChecks() { + const data = await listChecks(); + checks.set(data); + return data; +} diff --git a/web/src/lib/translations.ts b/web/src/lib/translations.ts index fb35aba1..48e0c7ae 100644 --- a/web/src/lib/translations.ts +++ b/web/src/lib/translations.ts @@ -39,6 +39,11 @@ interface Params { min?: number; max?: number; suggestion?: string; + key?: string; + error?: string; + providers?: string; + services?: string; + options?: string; // add more parameters that are used here } diff --git a/web/src/lib/utils/check.ts b/web/src/lib/utils/check.ts new file mode 100644 index 00000000..804acd1f --- /dev/null +++ b/web/src/lib/utils/check.ts @@ -0,0 +1,39 @@ +import type { HappydnsCheckResultStatus } from "$lib/api-base/types.gen"; +import { CheckResultStatus } from "$lib/model/check"; + +export function getStatusColor(status: CheckResultStatus | HappydnsCheckResultStatus | undefined): string { + switch (status) { + case CheckResultStatus.OK: + return "success"; + case CheckResultStatus.Info: + return "info"; + case CheckResultStatus.Warn: + return "warning"; + case CheckResultStatus.KO: + return "danger"; + default: + return "secondary"; + } +} + +export function getStatusKey(status: CheckResultStatus | HappydnsCheckResultStatus | undefined): string { + switch (status) { + case CheckResultStatus.OK: + return "checks.status.ok"; + case CheckResultStatus.Info: + return "checks.status.info"; + case CheckResultStatus.Warn: + return "checks.status.warning"; + case CheckResultStatus.KO: + return "checks.status.error"; + default: + return "checks.status.unknown"; + } +} + +export function formatDuration(duration: number | undefined, t: (k: string) => string): string { + if (!duration) return t("checks.na"); + const seconds = duration / 1000000000; + if (seconds < 1) return `${(seconds * 1000).toFixed(0)} ${t("checks.result.milliseconds")}`; + return `${seconds.toFixed(2)} ${t("checks.result.seconds")}`; +} diff --git a/web/src/lib/utils/datetime.ts b/web/src/lib/utils/datetime.ts index ea80159d..137872d3 100644 --- a/web/src/lib/utils/datetime.ts +++ b/web/src/lib/utils/datetime.ts @@ -4,17 +4,17 @@ * @returns Datetime-local format string, or empty string if invalid */ export function toDatetimeLocal(isoString: string | null | undefined): string { - if (!isoString) return ''; + if (!isoString) return ""; try { const date = new Date(isoString); const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - const hours = String(date.getHours()).padStart(2, '0'); - const minutes = String(date.getMinutes()).padStart(2, '0'); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); return `${year}-${month}-${day}T${hours}:${minutes}`; } catch (e) { - return ''; + return ""; } } @@ -31,3 +31,61 @@ export function fromDatetimeLocal(datetimeLocal: string): string | null { return null; } } + +/** + * Format a date string for display in check UI + * @param dateString ISO date string or undefined + * @param style Display style: "short", "medium", or "long" + * @param t i18n translation function + * @returns Formatted date string, or $t("checks.never") if undefined/invalid + */ +export function formatCheckDate( + dateString: string | undefined, + style: "short" | "medium" | "long", + t: (k: string) => string, +): string { + if (!dateString) return t("checks.never"); + const d = new Date(dateString); + if (isNaN(d.getTime())) return t("checks.never"); + return new Intl.DateTimeFormat(undefined, { + dateStyle: style, + timeStyle: "short", + }).format(d); +} + +/** + * Format a date string as a relative time (e.g. "in 3h 20m" or "5m ago") + * @param dateString ISO date string or undefined + * @param t i18n translation function + * @returns Relative time string, or empty string if undefined/invalid + */ +export function formatRelative(dateString: string | undefined, t: (k: string) => string): string { + if (!dateString) return ""; + const d = new Date(dateString); + if (isNaN(d.getTime())) return ""; + const now = new Date(); + const diffMs = d.getTime() - now.getTime(); + const absDiffMs = Math.abs(diffMs); + + if (absDiffMs < 60_000) + return diffMs > 0 + ? t("checks.relative.in-less-than-a-minute") + : t("checks.relative.just-now"); + + const minutes = Math.floor(absDiffMs / 60_000); + const hours = Math.floor(absDiffMs / 3_600_000); + const days = Math.floor(absDiffMs / 86_400_000); + + let label: string; + if (days > 0) { + label = `${days}d ${hours % 24}h`; + } else if (hours > 0) { + label = `${hours}h ${minutes % 60}m`; + } else { + label = `${minutes}m`; + } + + return diffMs > 0 + ? t("checks.relative.in").replace("{{label}}", label) + : t("checks.relative.ago").replace("{{label}}", label); +} diff --git a/web/src/lib/utils/index.ts b/web/src/lib/utils/index.ts index 8f586662..91179137 100644 --- a/web/src/lib/utils/index.ts +++ b/web/src/lib/utils/index.ts @@ -2,4 +2,5 @@ * Centralized utility exports */ -export { toDatetimeLocal, fromDatetimeLocal } from './datetime'; +export { toDatetimeLocal, fromDatetimeLocal, formatCheckDate, formatRelative } from "./datetime"; +export { getStatusColor, getStatusKey, formatDuration } from "./check"; diff --git a/web/src/routes/checks/+page.svelte b/web/src/routes/checks/+page.svelte new file mode 100644 index 00000000..28025bda --- /dev/null +++ b/web/src/routes/checks/+page.svelte @@ -0,0 +1,168 @@ + + + + + + {$t("checks.title")} - happyDomain + + + + + +

+ + {$t("checks.title")} +

+

+ + {$t("checks.description")} + + {#if $checks} + + {$t("checks.available-count", { + count: Object.keys($checks).length, + })} + + {/if} +

+ +
+ + + + + + + + + + + + + {#if !$checks} + +

+ + {$t("checks.loading")} +

+
+ {:else} +
+ + + + + + + + + + {#if Object.keys($checks).length == 0} + + + + {:else} + {#each Object.entries($checks).filter(([name, _info]) => name + .toLowerCase() + .indexOf(searchQuery.toLowerCase()) > -1) as [checkerName, checkerInfo]} + + + + + + {/each} + {/if} + +
{$t("checks.table.name")}{$t("checks.table.availability")}{$t("checks.table.actions")}
+ {$t("checks.no-tests")} +
{checkerInfo.name || checkerName} + {#if checkerInfo.availability} + {#if checkerInfo.availability.applyToDomain} + {$t("checks.availability.domain")} + {/if} + {#if checkerInfo.availability.limitToProviders && checkerInfo.availability.limitToProviders.length > 0} + + {$t("checks.availability.provider-specific")} + + {/if} + {#if checkerInfo.availability.limitToServices && checkerInfo.availability.limitToServices.length > 0} + + {$t("checks.availability.service-specific")} + + {/if} + {:else} + {$t("checks.availability.general")} + {/if} + + + + {$t("checks.actions.configure")} + +
+
+ {/if} +
diff --git a/web/src/routes/checks/[cid]/+page.svelte b/web/src/routes/checks/[cid]/+page.svelte new file mode 100644 index 00000000..c40c9f86 --- /dev/null +++ b/web/src/routes/checks/[cid]/+page.svelte @@ -0,0 +1,346 @@ + + + + + + {cid} - {$t("checks.title")} - happyDomain + + + + + + +

+ + {cid} +

+ +
+ + {#await checkStatusPromise} + +

+ + {$t("checks.loading-info")} +

+
+ {:then status} + {#if status} + + + + + {$t("checks.detail.test-information")} + + +
+
{$t("checks.detail.name")}
+
{status.name}
+ +
{$t("checks.detail.availability")}
+
+ {#if status.availability} +
+ {#if status.availability.applyToDomain} + {$t("checks.availability.domain-level")} + {/if} + {#if status.availability.limitToProviders && status.availability.limitToProviders.length > 0} + + {$t("checks.availability.providers", { + providers: + status.availability.limitToProviders.join( + ", ", + ), + })} + + {/if} + {#if status.availability.limitToServices && status.availability.limitToServices.length > 0} + + {$t("checks.availability.services", { + services: + status.availability.limitToServices.join( + ", ", + ), + })} + + {/if} + {#if !status.availability.applyToDomain && (!status.availability.limitToProviders || status.availability.limitToProviders.length === 0) && (!status.availability.limitToServices || status.availability.limitToServices.length === 0)} + {$t("checks.availability.general")} + {/if} +
+ {:else} + {$t("checks.availability.general")} + {/if} +
+
+
+
+ + + + {#await checkOptionsPromise} + + +

+ + {$t("checks.detail.loading-options")} +

+
+
+ {:then options} + {@const userOpts = status.options?.userOpts || []} + {@const readOnlyOptGroups = [ + { + key: "adminOpts", + label: $t("checks.option-groups.global-settings"), + opts: status.options?.adminOpts || [], + }, + { + key: "domainOpts", + label: $t("checks.option-groups.domain-settings"), + opts: status.options?.domainOpts || [], + }, + { + key: "serviceOpts", + label: $t("checks.option-groups.service-settings"), + opts: status.options?.serviceOpts || [], + }, + { + key: "runOpts", + label: $t("checks.option-groups.test-parameters"), + opts: status.options?.runOpts || [], + }, + ]} + {@const hasAnyOpts = + userOpts.length > 0 || readOnlyOptGroups.some((g) => g.opts.length > 0)} + {@const orphanedOpts = getOrphanedOptions(userOpts, readOnlyOptGroups)} + + {#if orphanedOpts.length > 0} + +
+
+ + {$t("checks.detail.orphaned-options", { + options: orphanedOpts.join(", "), + })} +
+ +
+
+ {/if} + + {#if userOpts.length > 0} + + + {$t("checks.detail.configuration")} + + +
{ + e.preventDefault(); + saveOptions(); + }} + > + {#each userOpts as optDoc} + {#if optDoc.id} + {@const optName = optDoc.id} + + + + {/if} + {/each} +
+ +
+
+
+
+ {/if} + + + + {#if !hasAnyOpts} + + + + + {$t("checks.detail.no-configurable-options")} + + + + {/if} + {:catch error} + + + + + {$t("checks.detail.error-loading-options", { + error: error.message, + })} + + + + {/await} + +
+ {:else} + + + {$t("checks.test-info-not-found")} + + {/if} + {:catch error} + + + {$t("checks.error-loading-test", { error: error.message })} + + {/await} +
diff --git a/web/src/routes/domains/[dn]/+layout.svelte b/web/src/routes/domains/[dn]/+layout.svelte index 02d6c59d..9e7be6b8 100644 --- a/web/src/routes/domains/[dn]/+layout.svelte +++ b/web/src/routes/domains/[dn]/+layout.svelte @@ -82,7 +82,11 @@ ? "/logs" : page.route.id.startsWith("/domains/[dn]/history") ? "/history" - : "" + : page.route.id.startsWith("/domains/[dn]/checks/[cname]") + ? `/checks/${page.params.cname!}` + : page.route.id.startsWith("/domains/[dn]/checks") + ? "/checks" + : "" : ""), ); } @@ -173,7 +177,35 @@ - {#if page.route.id && (page.route.id.startsWith("/domains/[dn]/history") || page.route.id.startsWith("/domains/[dn]/logs"))} + {#if page.route.id && page.route.id.startsWith("/domains/[dn]/checks/[cname]")} + {#if page.route.id.startsWith("/domains/[dn]/checks/[cname]/results/")} + + {:else} + + {/if} + {:else if page.route.id && (page.route.id.startsWith("/domains/[dn]/history") || page.route.id.startsWith("/domains/[dn]/logs") || page.route.id.startsWith("/domains/[dn]/checks"))} + + + + {#if loading} +
+ +

{$t("checks.list.loading")}

+
+ {:else if loadError} + +

+ + {$t("checks.list.error-loading", { error: loadError })} +

+
+ {:else if !check} + +

+ + {$t("checks.list.no-checks")} +

+
+ {:else} + + +

+ + {$t("checks.schedule.card-title")} +

+
+ +
+
+ + +
+
+ + {#if formEnabled} +
+ +
+ + + {$t("checks.schedule.hours")} + +
+
+ {$t("checks.schedule.interval-hint")} +
+
+ {/if} + + {#if check.schedule} +
+
+ {#if check.schedule.last_run} +
+ + {$t("checks.schedule.last-run")}: + + + {formatCheckDate(check.schedule.last_run, "medium", $t)} + + ({formatRelative(check.schedule.last_run, $t)}) + + +
+ {/if} + {#if check.enabled && check.schedule.next_run} +
+ + {$t("checks.schedule.next-run")}: + + + {formatCheckDate(check.schedule.next_run, "medium", $t)} + + ({formatRelative(check.schedule.next_run, $t)}) + + +
+ {/if} +
+
+ {:else} +

+ + {$t("checks.schedule.no-schedule-yet")} +

+ {/if} + + +
+
+ {/if} + diff --git a/web/src/routes/domains/[dn]/checks/[cname]/results/+page.svelte b/web/src/routes/domains/[dn]/checks/[cname]/results/+page.svelte new file mode 100644 index 00000000..473e107f --- /dev/null +++ b/web/src/routes/domains/[dn]/checks/[cname]/results/+page.svelte @@ -0,0 +1,312 @@ + + + + + + {checkName} Results - {data.domain.domain} - happyDomain + + +
+
+

+ {data.domain.domain} + – + {#await checkPromise then check} + {check.name || checkName} + {:catch} + {checkName} + {/await} +

+
+ + {#await checkPromise then check} + + {/await} +
+
+ + {#if errorMessage} + {#key errorMessage} + + + {errorMessage} + + {/key} + {/if} + + {#await resultsPromise} +
+ +

{$t("checks.results.loading")}

+
+ {:then results} + {#if !results || results.length === 0} + +

+ + {$t("checks.results.no-results")} +

+
+ {:else} +
+

{$t("checks.results.title", { count: results.length })}

+ +
+ + + + + + + + + + + + + + {#each pendingExecutions as exec (exec.id)} + + + + + + + + + {/each} + {#each results as result} + + + + + + + + + {/each} + +
{$t("checks.results.table.executed-at")}{$t("checks.results.table.status")}{$t("checks.results.table.message")}{$t("checks.results.table.duration")}{$t("checks.results.table.type")}{$t("checks.results.table.actions")}
+ {formatCheckDate(exec.started_at, "short", $t)} + + + {$t("checks.status.pending")} + + + {exec.status === CheckExecutionStatus.CheckExecutionRunning + ? $t("checks.results.pending.running") + : $t("checks.results.pending.queued")} + + + {#if exec.schedule_id} + + {$t("checks.results.type.scheduled")} + {:else} + + {$t("checks.results.type.manual")} + {/if} + +
+ {formatCheckDate(result.executed_at, "short", $t)} + + + {$t(getStatusKey(result.status))} + + + {result.status_line} + {#if result.error} +
+ {result.error} + {/if} +
+ {formatDuration(result.duration, $t)} + + + {#if result.scheduled_check} + + {$t("checks.results.type.scheduled")} + {:else} + + {$t("checks.results.type.manual")} + {/if} + + + + + + +
+ {/if} + {:catch error} + +

+ + {$t("checks.results.error-loading", { error: error.message })} +

+
+ {/await} +
+ + diff --git a/web/src/routes/domains/[dn]/checks/[cname]/results/[rid]/+page.svelte b/web/src/routes/domains/[dn]/checks/[cname]/results/[rid]/+page.svelte new file mode 100644 index 00000000..1b69fad8 --- /dev/null +++ b/web/src/routes/domains/[dn]/checks/[cname]/results/[rid]/+page.svelte @@ -0,0 +1,305 @@ + + + + + + + Check Result - {checkName} - {data.domain.domain} - happyDomain + + + +
+
+

+ {data.domain.domain} + – + {$t("checks.result.title")} +

+
+ + +
+
+ + {#if errorMessage} + {#key errorMessage} + + + {errorMessage} + + {/key} + {/if} + + {#await Promise.all([resultPromise, checkPromise])} +
+ +

{$t("checks.result.loading")}

+
+ {:then [result, check]} + + + + +
+
+

+ {check.name || checkName} +

+
+ {#if result.scheduled_check} + + + {$t("checks.result.type.scheduled")} + + {:else} + + + {$t("checks.result.type.manual")} + + {/if} +
+
+ + + + + + + + + + + + + + + + + + + + + + + + {#if result.error} + + + + + {/if} + +
{$t("checks.result.field.domain")}{data.domain.domain}
{$t("checks.result.field.executed-at")}{formatCheckDate(result.executed_at, "long", $t)}
{$t("checks.result.field.duration")}{formatDuration(result.duration, $t)}
{$t("checks.result.field.status")} + + {$t(getStatusKey(result.status))} + +
{$t("checks.result.field.status-message")}{result.status_line}
{$t("checks.result.field.error")}{result.error}
+
+
+ + {#if result.options && Object.keys(result.options).length > 0} + + + +
+ + {$t("checks.result.check-options")} +
+
+ + + + {#each Object.entries(check.options ?? {}) as [optKey, optVals]} + {#each optVals as option} + {@const value = + (option.id + ? result.options[option.id] + : undefined) || + option.default || + option.placeholder || + ""} + + + + + {/each} + {/each} + +
+ {option.label}: + + {#if typeof value === "object"} +
{JSON.stringify(
+                                                                    value,
+                                                                    null,
+                                                                    2,
+                                                                )}
+ {:else} + {value} + {/if} +
+
+
+ + {/if} +
+ + {#if result.report} + + +
+ + {$t("checks.result.full-report")} +
+
+ + {#if typeof result.report === "string"} +
{result.report}
+ {:else} +
{JSON.stringify(result.report, null, 2)}
+ {/if} +
+
+ {/if} + {:catch error} + +

+ + {$t("checks.result.error-loading", { error: error.message })} +

+
+ {/await} +
+ +