Files
Charon/backend/internal/api/handlers/manual_challenge_handler.go
2026-03-04 18:34:49 +00:00

667 lines
18 KiB
Go

package handlers
import (
"context"
"errors"
"net/http"
"strconv"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
)
// ManualChallengeServiceInterface defines the interface for manual challenge operations.
// This allows for easier testing by enabling mock implementations.
type ManualChallengeServiceInterface interface {
CreateChallenge(ctx context.Context, req services.CreateChallengeRequest) (*models.ManualChallenge, error)
GetChallengeForUser(ctx context.Context, challengeID string, userID uint) (*models.ManualChallenge, error)
ListChallengesForProvider(ctx context.Context, providerID, userID uint) ([]models.ManualChallenge, error)
VerifyChallenge(ctx context.Context, challengeID string, userID uint) (*services.VerifyResult, error)
PollChallengeStatus(ctx context.Context, challengeID string, userID uint) (*services.ChallengeStatusResponse, error)
DeleteChallenge(ctx context.Context, challengeID string, userID uint) error
}
// DNSProviderServiceInterface defines the subset of DNSProviderService needed by ManualChallengeHandler.
type DNSProviderServiceInterface interface {
Get(ctx context.Context, id uint) (*models.DNSProvider, error)
}
// ManualChallengeHandler handles manual DNS challenge API requests.
type ManualChallengeHandler struct {
challengeService ManualChallengeServiceInterface
providerService DNSProviderServiceInterface
}
// NewManualChallengeHandler creates a new manual challenge handler.
func NewManualChallengeHandler(challengeService ManualChallengeServiceInterface, providerService DNSProviderServiceInterface) *ManualChallengeHandler {
return &ManualChallengeHandler{
challengeService: challengeService,
providerService: providerService,
}
}
// ManualChallengeResponse represents the API response for a manual challenge.
type ManualChallengeResponse struct {
ID string `json:"id"`
ProviderID uint `json:"provider_id"`
FQDN string `json:"fqdn"`
Value string `json:"value"`
Status string `json:"status"`
DNSPropagated bool `json:"dns_propagated"`
CreatedAt string `json:"created_at"`
ExpiresAt string `json:"expires_at"`
LastCheckAt string `json:"last_check_at,omitempty"`
TimeRemainingSeconds int `json:"time_remaining_seconds"`
ErrorMessage string `json:"error_message,omitempty"`
}
// ErrorResponse represents an error response with a code.
type ErrorResponse struct {
Success bool `json:"success"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
Details map[string]interface{} `json:"details,omitempty"`
} `json:"error"`
}
// newErrorResponse creates a standardized error response.
func newErrorResponse(code, message string, details map[string]interface{}) ErrorResponse {
resp := ErrorResponse{Success: false}
resp.Error.Code = code
resp.Error.Message = message
resp.Error.Details = details
return resp
}
// GetChallenge handles GET /api/v1/dns-providers/:id/manual-challenge/:challengeId
// Returns the status and details of a manual DNS challenge.
func (h *ManualChallengeHandler) GetChallenge(c *gin.Context) {
providerID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, newErrorResponse(
"INVALID_PROVIDER_ID",
"Invalid provider ID",
nil,
))
return
}
challengeID := c.Param("challengeId")
if challengeID == "" {
c.JSON(http.StatusBadRequest, newErrorResponse(
"INVALID_CHALLENGE_ID",
"Challenge ID is required",
nil,
))
return
}
// Get user ID from context (set by auth middleware)
userID := getUserIDFromContext(c)
// Verify provider exists and user has access
provider, err := h.providerService.Get(c.Request.Context(), uint(providerID))
if err != nil {
if errors.Is(err, services.ErrDNSProviderNotFound) {
c.JSON(http.StatusNotFound, newErrorResponse(
"PROVIDER_NOT_FOUND",
"DNS provider not found",
nil,
))
return
}
c.JSON(http.StatusInternalServerError, newErrorResponse(
"INTERNAL_ERROR",
"Failed to retrieve DNS provider",
nil,
))
return
}
// Verify provider is manual type
if provider.ProviderType != "manual" {
c.JSON(http.StatusBadRequest, newErrorResponse(
"INVALID_PROVIDER_TYPE",
"This endpoint is only available for manual DNS providers",
nil,
))
return
}
// Get challenge
challenge, err := h.challengeService.GetChallengeForUser(c.Request.Context(), challengeID, userID)
if err != nil {
if errors.Is(err, services.ErrChallengeNotFound) {
c.JSON(http.StatusNotFound, newErrorResponse(
"CHALLENGE_NOT_FOUND",
"Challenge not found",
map[string]interface{}{"challenge_id": challengeID},
))
return
}
if errors.Is(err, services.ErrUnauthorized) {
c.JSON(http.StatusForbidden, newErrorResponse(
"UNAUTHORIZED",
"You do not have access to this challenge",
nil,
))
return
}
c.JSON(http.StatusInternalServerError, newErrorResponse(
"INTERNAL_ERROR",
"Failed to retrieve challenge",
nil,
))
return
}
// Verify challenge belongs to the specified provider
if challenge.ProviderID != uint(providerID) {
c.JSON(http.StatusNotFound, newErrorResponse(
"CHALLENGE_NOT_FOUND",
"Challenge not found for this provider",
nil,
))
return
}
c.JSON(http.StatusOK, challengeToResponse(challenge))
}
// VerifyChallenge handles POST /api/v1/dns-providers/:id/manual-challenge/:challengeId/verify
// Triggers DNS verification for a challenge.
func (h *ManualChallengeHandler) VerifyChallenge(c *gin.Context) {
providerID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, newErrorResponse(
"INVALID_PROVIDER_ID",
"Invalid provider ID",
nil,
))
return
}
challengeID := c.Param("challengeId")
if challengeID == "" {
c.JSON(http.StatusBadRequest, newErrorResponse(
"INVALID_CHALLENGE_ID",
"Challenge ID is required",
nil,
))
return
}
// Get user ID from context
userID := getUserIDFromContext(c)
// Verify provider exists and is manual type
provider, err := h.providerService.Get(c.Request.Context(), uint(providerID))
if err != nil {
if errors.Is(err, services.ErrDNSProviderNotFound) {
c.JSON(http.StatusNotFound, newErrorResponse(
"PROVIDER_NOT_FOUND",
"DNS provider not found",
nil,
))
return
}
c.JSON(http.StatusInternalServerError, newErrorResponse(
"INTERNAL_ERROR",
"Failed to retrieve DNS provider",
nil,
))
return
}
if provider.ProviderType != "manual" {
c.JSON(http.StatusBadRequest, newErrorResponse(
"INVALID_PROVIDER_TYPE",
"This endpoint is only available for manual DNS providers",
nil,
))
return
}
// Verify ownership before verification
challenge, err := h.challengeService.GetChallengeForUser(c.Request.Context(), challengeID, userID)
if err != nil {
if errors.Is(err, services.ErrChallengeNotFound) {
c.JSON(http.StatusNotFound, newErrorResponse(
"CHALLENGE_NOT_FOUND",
"Challenge not found",
map[string]interface{}{"challenge_id": challengeID},
))
return
}
if errors.Is(err, services.ErrUnauthorized) {
c.JSON(http.StatusForbidden, newErrorResponse(
"UNAUTHORIZED",
"You do not have access to this challenge",
nil,
))
return
}
c.JSON(http.StatusInternalServerError, newErrorResponse(
"INTERNAL_ERROR",
"Failed to retrieve challenge",
nil,
))
return
}
if challenge.ProviderID != uint(providerID) {
c.JSON(http.StatusNotFound, newErrorResponse(
"CHALLENGE_NOT_FOUND",
"Challenge not found for this provider",
nil,
))
return
}
// Perform verification
result, err := h.challengeService.VerifyChallenge(c.Request.Context(), challengeID, userID)
if err != nil {
if errors.Is(err, services.ErrChallengeExpired) {
c.JSON(http.StatusGone, newErrorResponse(
"CHALLENGE_EXPIRED",
"Challenge has expired",
map[string]interface{}{
"challenge_id": challengeID,
"expired_at": challenge.ExpiresAt.Format("2006-01-02T15:04:05Z"),
},
))
return
}
c.JSON(http.StatusInternalServerError, newErrorResponse(
"INTERNAL_ERROR",
"Failed to verify challenge",
nil,
))
return
}
c.JSON(http.StatusOK, result)
}
// PollChallenge handles GET /api/v1/dns-providers/:id/manual-challenge/:challengeId/poll
// Returns the current status for polling.
func (h *ManualChallengeHandler) PollChallenge(c *gin.Context) {
providerID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, newErrorResponse(
"INVALID_PROVIDER_ID",
"Invalid provider ID",
nil,
))
return
}
challengeID := c.Param("challengeId")
if challengeID == "" {
c.JSON(http.StatusBadRequest, newErrorResponse(
"INVALID_CHALLENGE_ID",
"Challenge ID is required",
nil,
))
return
}
userID := getUserIDFromContext(c)
// Verify provider exists
provider, err := h.providerService.Get(c.Request.Context(), uint(providerID))
if err != nil {
if errors.Is(err, services.ErrDNSProviderNotFound) {
c.JSON(http.StatusNotFound, newErrorResponse(
"PROVIDER_NOT_FOUND",
"DNS provider not found",
nil,
))
return
}
c.JSON(http.StatusInternalServerError, newErrorResponse(
"INTERNAL_ERROR",
"Failed to retrieve DNS provider",
nil,
))
return
}
if provider.ProviderType != "manual" {
c.JSON(http.StatusBadRequest, newErrorResponse(
"INVALID_PROVIDER_TYPE",
"This endpoint is only available for manual DNS providers",
nil,
))
return
}
// Get challenge status
status, err := h.challengeService.PollChallengeStatus(c.Request.Context(), challengeID, userID)
if err != nil {
if errors.Is(err, services.ErrChallengeNotFound) {
c.JSON(http.StatusNotFound, newErrorResponse(
"CHALLENGE_NOT_FOUND",
"Challenge not found",
nil,
))
return
}
if errors.Is(err, services.ErrUnauthorized) {
c.JSON(http.StatusForbidden, newErrorResponse(
"UNAUTHORIZED",
"You do not have access to this challenge",
nil,
))
return
}
c.JSON(http.StatusInternalServerError, newErrorResponse(
"INTERNAL_ERROR",
"Failed to get challenge status",
nil,
))
return
}
c.JSON(http.StatusOK, status)
}
// ListChallenges handles GET /api/v1/dns-providers/:id/manual-challenges
// Returns all challenges for a provider.
func (h *ManualChallengeHandler) ListChallenges(c *gin.Context) {
providerID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, newErrorResponse(
"INVALID_PROVIDER_ID",
"Invalid provider ID",
nil,
))
return
}
userID := getUserIDFromContext(c)
// Verify provider exists and is manual type
provider, err := h.providerService.Get(c.Request.Context(), uint(providerID))
if err != nil {
if errors.Is(err, services.ErrDNSProviderNotFound) {
c.JSON(http.StatusNotFound, newErrorResponse(
"PROVIDER_NOT_FOUND",
"DNS provider not found",
nil,
))
return
}
c.JSON(http.StatusInternalServerError, newErrorResponse(
"INTERNAL_ERROR",
"Failed to retrieve DNS provider",
nil,
))
return
}
if provider.ProviderType != "manual" {
c.JSON(http.StatusBadRequest, newErrorResponse(
"INVALID_PROVIDER_TYPE",
"This endpoint is only available for manual DNS providers",
nil,
))
return
}
challenges, err := h.challengeService.ListChallengesForProvider(c.Request.Context(), uint(providerID), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, newErrorResponse(
"INTERNAL_ERROR",
"Failed to list challenges",
nil,
))
return
}
responses := make([]ManualChallengeResponse, len(challenges))
for i, ch := range challenges {
responses[i] = *challengeToResponse(&ch)
}
c.JSON(http.StatusOK, gin.H{
"challenges": responses,
"total": len(responses),
})
}
// DeleteChallenge handles DELETE /api/v1/dns-providers/:id/manual-challenge/:challengeId
// Cancels/deletes a challenge.
func (h *ManualChallengeHandler) DeleteChallenge(c *gin.Context) {
providerID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, newErrorResponse(
"INVALID_PROVIDER_ID",
"Invalid provider ID",
nil,
))
return
}
challengeID := c.Param("challengeId")
if challengeID == "" {
c.JSON(http.StatusBadRequest, newErrorResponse(
"INVALID_CHALLENGE_ID",
"Challenge ID is required",
nil,
))
return
}
userID := getUserIDFromContext(c)
// Verify provider exists
provider, err := h.providerService.Get(c.Request.Context(), uint(providerID))
if err != nil {
if errors.Is(err, services.ErrDNSProviderNotFound) {
c.JSON(http.StatusNotFound, newErrorResponse(
"PROVIDER_NOT_FOUND",
"DNS provider not found",
nil,
))
return
}
c.JSON(http.StatusInternalServerError, newErrorResponse(
"INTERNAL_ERROR",
"Failed to retrieve DNS provider",
nil,
))
return
}
if provider.ProviderType != "manual" {
c.JSON(http.StatusBadRequest, newErrorResponse(
"INVALID_PROVIDER_TYPE",
"This endpoint is only available for manual DNS providers",
nil,
))
return
}
err = h.challengeService.DeleteChallenge(c.Request.Context(), challengeID, userID)
if err != nil {
if errors.Is(err, services.ErrChallengeNotFound) {
c.JSON(http.StatusNotFound, newErrorResponse(
"CHALLENGE_NOT_FOUND",
"Challenge not found",
nil,
))
return
}
if errors.Is(err, services.ErrUnauthorized) {
c.JSON(http.StatusForbidden, newErrorResponse(
"UNAUTHORIZED",
"You do not have access to this challenge",
nil,
))
return
}
c.JSON(http.StatusInternalServerError, newErrorResponse(
"INTERNAL_ERROR",
"Failed to delete challenge",
nil,
))
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Challenge deleted successfully",
})
}
// CreateChallengeRequest represents the request to create a manual challenge.
type CreateChallengeRequest struct {
FQDN string `json:"fqdn" binding:"required"`
Token string `json:"token"`
Value string `json:"value" binding:"required"`
}
// CreateChallenge handles POST /api/v1/dns-providers/:id/manual-challenges
// Creates a new manual DNS challenge.
func (h *ManualChallengeHandler) CreateChallenge(c *gin.Context) {
providerID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, newErrorResponse(
"INVALID_PROVIDER_ID",
"Invalid provider ID",
nil,
))
return
}
var req CreateChallengeRequest
if bindErr := c.ShouldBindJSON(&req); bindErr != nil {
c.JSON(http.StatusBadRequest, newErrorResponse(
"INVALID_REQUEST",
bindErr.Error(),
nil,
))
return
}
userID := getUserIDFromContext(c)
// Verify provider exists and is manual type
provider, err := h.providerService.Get(c.Request.Context(), uint(providerID))
if err != nil {
if errors.Is(err, services.ErrDNSProviderNotFound) {
c.JSON(http.StatusNotFound, newErrorResponse(
"PROVIDER_NOT_FOUND",
"DNS provider not found",
nil,
))
return
}
c.JSON(http.StatusInternalServerError, newErrorResponse(
"INTERNAL_ERROR",
"Failed to retrieve DNS provider",
nil,
))
return
}
if provider.ProviderType != "manual" {
c.JSON(http.StatusBadRequest, newErrorResponse(
"INVALID_PROVIDER_TYPE",
"This endpoint is only available for manual DNS providers",
nil,
))
return
}
challenge, err := h.challengeService.CreateChallenge(c.Request.Context(), services.CreateChallengeRequest{
ProviderID: uint(providerID),
UserID: userID,
FQDN: req.FQDN,
Token: req.Token,
Value: req.Value,
})
if err != nil {
if errors.Is(err, services.ErrChallengeInProgress) {
c.JSON(http.StatusConflict, newErrorResponse(
"CHALLENGE_IN_PROGRESS",
"Another challenge is already in progress for this domain",
map[string]interface{}{"fqdn": req.FQDN},
))
return
}
c.JSON(http.StatusInternalServerError, newErrorResponse(
"INTERNAL_ERROR",
"Failed to create challenge",
nil,
))
return
}
c.JSON(http.StatusCreated, challengeToResponse(challenge))
}
// RegisterRoutes registers all manual challenge routes.
func (h *ManualChallengeHandler) RegisterRoutes(rg *gin.RouterGroup) {
// Routes under /dns-providers/:id
rg.GET("/dns-providers/:id/manual-challenges", h.ListChallenges)
rg.POST("/dns-providers/:id/manual-challenges", h.CreateChallenge)
rg.GET("/dns-providers/:id/manual-challenge/:challengeId", h.GetChallenge)
rg.POST("/dns-providers/:id/manual-challenge/:challengeId/verify", h.VerifyChallenge)
rg.GET("/dns-providers/:id/manual-challenge/:challengeId/poll", h.PollChallenge)
rg.DELETE("/dns-providers/:id/manual-challenge/:challengeId", h.DeleteChallenge)
}
// Helper functions
func challengeToResponse(ch *models.ManualChallenge) *ManualChallengeResponse {
resp := &ManualChallengeResponse{
ID: ch.ID,
ProviderID: ch.ProviderID,
FQDN: ch.FQDN,
Value: ch.Value,
Status: string(ch.Status),
DNSPropagated: ch.DNSPropagated,
CreatedAt: ch.CreatedAt.Format("2006-01-02T15:04:05Z"),
ExpiresAt: ch.ExpiresAt.Format("2006-01-02T15:04:05Z"),
TimeRemainingSeconds: int(ch.TimeRemaining().Seconds()),
ErrorMessage: ch.ErrorMessage,
}
if ch.LastCheckAt != nil {
resp.LastCheckAt = ch.LastCheckAt.Format("2006-01-02T15:04:05Z")
}
return resp
}
// getUserIDFromContext extracts user ID from gin context.
func getUserIDFromContext(c *gin.Context) uint {
// Try to get user_id from context (set by auth middleware)
if userID, exists := c.Get("user_id"); exists {
switch v := userID.(type) {
case uint:
return v
case int:
// Check for overflow when converting int -> uint
if v < 0 {
return 0 // Invalid negative ID
}
return uint(v) // #nosec G115 -- validated non-negative
case int64:
// Check for overflow when converting int64 -> uint
// Use simple bounds check instead of complex expression
if v < 0 || v > 4294967295 { // Max uint32, safe for most systems
return 0 // Out of valid range
}
return uint(v) // #nosec G115 -- validated range
case uint64:
return uint(v)
}
}
return 0
}