Introduce CheckerHTMLReporter interface that observation providers can implement to render rich HTML documents from their data. The Zonemaster provider implements it with collapsible accordions and severity badges. Adds API endpoint GET .../observations/:obsKey/report, frontend stores for view mode switching (HTML/JSON), and wires the sidebar toggle buttons.
326 lines
13 KiB
Go
326 lines
13 KiB
Go
// This file is part of the happyDomain (R) project.
|
|
// Copyright (c) 2020-2026 happyDomain
|
|
// Authors: Pierre-Olivier Mercier, et al.
|
|
//
|
|
// This program is offered under a commercial and under the AGPL license.
|
|
// For commercial licensing, contact us at <contact@happydomain.org>.
|
|
//
|
|
// For AGPL licensing:
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
package controller
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"git.happydns.org/happyDomain/internal/api/middleware"
|
|
checkerPkg "git.happydns.org/happyDomain/internal/checker"
|
|
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
|
|
"git.happydns.org/happyDomain/model"
|
|
)
|
|
|
|
// ExecutionHandler is a middleware that validates the executionId path parameter,
|
|
// checks target scope, and sets "execution" in context.
|
|
func (cc *CheckerController) ExecutionHandler(c *gin.Context) {
|
|
execID, err := happydns.NewIdentifierFromString(c.Param("executionId"))
|
|
if err != nil {
|
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid execution ID"})
|
|
return
|
|
}
|
|
|
|
exec, err := cc.statusUC.GetExecution(targetFromContext(c), execID)
|
|
if err != nil {
|
|
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"})
|
|
return
|
|
}
|
|
|
|
c.Set("execution", exec)
|
|
c.Next()
|
|
}
|
|
|
|
// ListExecutions returns executions for a checker on a target.
|
|
//
|
|
// @Summary List executions for a checker
|
|
// @Tags checkers
|
|
// @Produce json
|
|
// @Param checkerId path string true "Checker ID"
|
|
// @Param limit query int false "Maximum number of results"
|
|
// @Param include_planned query bool false "Include upcoming planned executions from the scheduler"
|
|
// @Param domain path string true "Domain identifier"
|
|
// @Param zoneid path string true "Zone identifier"
|
|
// @Param subdomain path string true "Subdomain"
|
|
// @Param serviceid path string true "Service identifier"
|
|
// @Success 200 {array} happydns.Execution
|
|
// @Router /domains/{domain}/checkers/{checkerId}/executions [get]
|
|
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions [get]
|
|
func (cc *CheckerController) ListExecutions(c *gin.Context) {
|
|
cname := c.Param("checkerId")
|
|
target := targetFromContext(c)
|
|
|
|
limit := getLimitParam(c, 0)
|
|
|
|
execs, err := cc.statusUC.ListExecutionsByChecker(cname, target, limit)
|
|
if err != nil {
|
|
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
if execs == nil {
|
|
execs = []*happydns.Execution{}
|
|
}
|
|
|
|
if c.Query("include_planned") == "true" || c.Query("include_planned") == "1" {
|
|
planned := checkerUC.ListPlannedExecutions(cc.plannedProvider, cname, target)
|
|
execs = append(planned, execs...)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, execs)
|
|
}
|
|
|
|
// DeleteExecution deletes an execution record.
|
|
//
|
|
// @Summary Delete an execution
|
|
// @Tags checkers
|
|
// @Produce json
|
|
// @Param checkerId path string true "Checker ID"
|
|
// @Param executionId path string true "Execution ID"
|
|
// @Param domain path string true "Domain identifier"
|
|
// @Param zoneid path string true "Zone identifier"
|
|
// @Param subdomain path string true "Subdomain"
|
|
// @Param serviceid path string true "Service identifier"
|
|
// @Success 204
|
|
// @Failure 400 {object} happydns.ErrorResponse
|
|
// @Failure 404 {object} happydns.ErrorResponse
|
|
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId} [delete]
|
|
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId} [delete]
|
|
func (cc *CheckerController) DeleteExecution(c *gin.Context) {
|
|
exec := c.MustGet("execution").(*happydns.Execution)
|
|
|
|
if err := cc.statusUC.DeleteExecution(targetFromContext(c), exec.Id); err != nil {
|
|
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
// DeleteCheckerExecutions deletes all executions for a checker on a target.
|
|
//
|
|
// @Summary Delete all executions for a checker
|
|
// @Tags checkers
|
|
// @Produce json
|
|
// @Param checkerId path string true "Checker ID"
|
|
// @Param domain path string true "Domain identifier"
|
|
// @Param zoneid path string true "Zone identifier"
|
|
// @Param subdomain path string true "Subdomain"
|
|
// @Param serviceid path string true "Service identifier"
|
|
// @Success 204
|
|
// @Failure 400 {object} happydns.ErrorResponse
|
|
// @Failure 404 {object} happydns.ErrorResponse
|
|
// @Router /domains/{domain}/checkers/{checkerId}/executions [delete]
|
|
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions [delete]
|
|
func (cc *CheckerController) DeleteCheckerExecutions(c *gin.Context) {
|
|
cname := c.Param("checkerId")
|
|
target := targetFromContext(c)
|
|
|
|
if err := cc.statusUC.DeleteExecutionsByChecker(cname, target); err != nil {
|
|
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
// GetExecutionObservations returns the observation snapshot for an execution.
|
|
//
|
|
// @Summary Get observations for an execution
|
|
// @Tags checkers
|
|
// @Produce json
|
|
// @Param checkerId path string true "Checker ID"
|
|
// @Param executionId path string true "Execution ID"
|
|
// @Param domain path string true "Domain identifier"
|
|
// @Param zoneid path string true "Zone identifier"
|
|
// @Param subdomain path string true "Subdomain"
|
|
// @Param serviceid path string true "Service identifier"
|
|
// @Success 200 {object} happydns.ObservationSnapshot
|
|
// @Failure 404 {object} happydns.ErrorResponse
|
|
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/observations [get]
|
|
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/observations [get]
|
|
func (cc *CheckerController) GetExecutionObservations(c *gin.Context) {
|
|
exec := c.MustGet("execution").(*happydns.Execution)
|
|
|
|
snap, err := cc.statusUC.GetObservationsByExecution(targetFromContext(c), exec.Id)
|
|
if err != nil {
|
|
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Observations not available"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, snap)
|
|
}
|
|
|
|
// GetExecutionObservation returns a specific observation key from an execution's snapshot.
|
|
//
|
|
// @Summary Get a specific observation for an execution
|
|
// @Tags checkers
|
|
// @Produce json
|
|
// @Param checkerId path string true "Checker ID"
|
|
// @Param executionId path string true "Execution ID"
|
|
// @Param obsKey path string true "Observation key"
|
|
// @Param domain path string true "Domain identifier"
|
|
// @Param zoneid path string true "Zone identifier"
|
|
// @Param subdomain path string true "Subdomain"
|
|
// @Param serviceid path string true "Service identifier"
|
|
// @Success 200 {object} any
|
|
// @Failure 404 {object} happydns.ErrorResponse
|
|
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/observations/{obsKey} [get]
|
|
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/observations/{obsKey} [get]
|
|
func (cc *CheckerController) GetExecutionObservation(c *gin.Context) {
|
|
exec := c.MustGet("execution").(*happydns.Execution)
|
|
|
|
snap, err := cc.statusUC.GetObservationsByExecution(targetFromContext(c), exec.Id)
|
|
if err != nil {
|
|
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Observations not available"})
|
|
return
|
|
}
|
|
|
|
obsKey := c.Param("obsKey")
|
|
val, ok := snap.Data[obsKey]
|
|
if !ok {
|
|
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Observation key not found"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, val)
|
|
}
|
|
|
|
// GetExecutionResults returns the evaluation (per-rule states) for an execution.
|
|
//
|
|
// @Summary Get results for an execution
|
|
// @Tags checkers
|
|
// @Produce json
|
|
// @Param checkerId path string true "Checker ID"
|
|
// @Param executionId path string true "Execution ID"
|
|
// @Param domain path string true "Domain identifier"
|
|
// @Param zoneid path string true "Zone identifier"
|
|
// @Param subdomain path string true "Subdomain"
|
|
// @Param serviceid path string true "Service identifier"
|
|
// @Success 200 {object} happydns.CheckEvaluation
|
|
// @Failure 404 {object} happydns.ErrorResponse
|
|
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/results [get]
|
|
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/results [get]
|
|
func (cc *CheckerController) GetExecutionResults(c *gin.Context) {
|
|
exec := c.MustGet("execution").(*happydns.Execution)
|
|
|
|
eval, err := cc.statusUC.GetResultsByExecution(targetFromContext(c), exec.Id)
|
|
if err != nil {
|
|
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Results not available"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, eval)
|
|
}
|
|
|
|
// GetExecutionResult returns a specific rule's result from an execution.
|
|
//
|
|
// @Summary Get a specific rule result for an execution
|
|
// @Tags checkers
|
|
// @Produce json
|
|
// @Param checkerId path string true "Checker ID"
|
|
// @Param executionId path string true "Execution ID"
|
|
// @Param ruleName path string true "Rule name"
|
|
// @Param domain path string true "Domain identifier"
|
|
// @Param zoneid path string true "Zone identifier"
|
|
// @Param subdomain path string true "Subdomain"
|
|
// @Param serviceid path string true "Service identifier"
|
|
// @Success 200 {object} checker.CheckState
|
|
// @Failure 404 {object} happydns.ErrorResponse
|
|
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/results/{ruleName} [get]
|
|
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/results/{ruleName} [get]
|
|
func (cc *CheckerController) GetExecutionResult(c *gin.Context) {
|
|
exec := c.MustGet("execution").(*happydns.Execution)
|
|
|
|
eval, err := cc.statusUC.GetResultsByExecution(targetFromContext(c), exec.Id)
|
|
if err != nil {
|
|
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Results not available"})
|
|
return
|
|
}
|
|
|
|
ruleName := c.Param("ruleName")
|
|
for _, state := range eval.States {
|
|
if state.Code == ruleName {
|
|
c.JSON(http.StatusOK, state)
|
|
return
|
|
}
|
|
}
|
|
|
|
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Rule result not found"})
|
|
}
|
|
|
|
// GetExecutionHTMLReport returns the HTML report for a specific observation of an execution.
|
|
//
|
|
// @Summary Get execution observation HTML report
|
|
// @Description Returns the full HTML document generated from an observation's data. Only available for observation providers that implement HTML reporting.
|
|
// @Tags checkers
|
|
// @Produce html
|
|
// @Param checkerId path string true "Checker ID"
|
|
// @Param executionId path string true "Execution ID"
|
|
// @Param obsKey path string true "Observation key"
|
|
// @Param domain path string true "Domain identifier"
|
|
// @Param zoneid path string true "Zone identifier"
|
|
// @Param subdomain path string true "Subdomain"
|
|
// @Param serviceid path string true "Service identifier"
|
|
// @Success 200 {string} string "HTML document"
|
|
// @Failure 404 {object} happydns.ErrorResponse
|
|
// @Failure 500 {object} happydns.ErrorResponse
|
|
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/observations/{obsKey}/report [get]
|
|
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/observations/{obsKey}/report [get]
|
|
func (cc *CheckerController) GetExecutionHTMLReport(c *gin.Context) {
|
|
exec := c.MustGet("execution").(*happydns.Execution)
|
|
|
|
snap, err := cc.statusUC.GetObservationsByExecution(targetFromContext(c), exec.Id)
|
|
if err != nil {
|
|
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Observations not available"})
|
|
return
|
|
}
|
|
|
|
obsKey := c.Param("obsKey")
|
|
val, ok := snap.Data[obsKey]
|
|
if !ok {
|
|
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Observation key not found"})
|
|
return
|
|
}
|
|
|
|
raw, err := json.Marshal(val)
|
|
if err != nil {
|
|
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
htmlContent, supported, err := checkerPkg.GetHTMLReport(obsKey, json.RawMessage(raw))
|
|
if err != nil {
|
|
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
if !supported {
|
|
middleware.ErrorResponse(c, http.StatusNotFound, fmt.Errorf("observation %q does not support HTML reports", obsKey))
|
|
return
|
|
}
|
|
|
|
c.Header("Content-Security-Policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'self'")
|
|
c.Header("X-Content-Type-Options", "nosniff")
|
|
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(htmlContent))
|
|
}
|