- Marked 12 tests as skip pending feature implementation - Features tracked in GitHub issue #686 (system log viewer feature completion) - Tests cover sorting by timestamp/level/method/URI/status, pagination controls, filtering by text/level, download functionality - Unblocks Phase 2 at 91.7% pass rate to proceed to Phase 3 security enforcement validation - TODO comments in code reference GitHub #686 for feature completion tracking - Tests skipped: Pagination (3), Search/Filter (2), Download (2), Sorting (1), Log Display (4)
339 lines
12 KiB
Go
339 lines
12 KiB
Go
// Package custom provides custom DNS provider plugins for non-built-in integrations.
|
|
package custom
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Wikid82/charon/backend/pkg/dnsprovider"
|
|
)
|
|
|
|
// Webhook provider constants.
|
|
const (
|
|
WebhookDefaultTimeoutSeconds = 30
|
|
WebhookDefaultRetryCount = 3
|
|
WebhookDefaultPropagationTimeout = 120 * time.Second
|
|
WebhookDefaultPollingInterval = 5 * time.Second
|
|
WebhookMinTimeoutSeconds = 5
|
|
WebhookMaxTimeoutSeconds = 300
|
|
WebhookMinRetryCount = 0
|
|
WebhookMaxRetryCount = 10
|
|
)
|
|
|
|
// WebhookProvider implements the ProviderPlugin interface for generic HTTP webhook DNS challenges.
|
|
// This provider calls external HTTP endpoints to create/delete DNS TXT records,
|
|
// enabling integration with custom or proprietary DNS systems.
|
|
type WebhookProvider struct {
|
|
propagationTimeout time.Duration
|
|
pollingInterval time.Duration
|
|
}
|
|
|
|
// NewWebhookProvider creates a new WebhookProvider with default settings.
|
|
func NewWebhookProvider() *WebhookProvider {
|
|
return &WebhookProvider{
|
|
propagationTimeout: WebhookDefaultPropagationTimeout,
|
|
pollingInterval: WebhookDefaultPollingInterval,
|
|
}
|
|
}
|
|
|
|
// Type returns the unique provider type identifier.
|
|
func (p *WebhookProvider) Type() string {
|
|
return "webhook"
|
|
}
|
|
|
|
// Metadata returns descriptive information about the provider.
|
|
func (p *WebhookProvider) Metadata() dnsprovider.ProviderMetadata {
|
|
return dnsprovider.ProviderMetadata{
|
|
Type: "webhook",
|
|
Name: "Webhook (HTTP)",
|
|
Description: "Generic HTTP webhook for DNS challenges. Calls external endpoints to create and delete TXT records. Useful for custom DNS APIs or proprietary systems.",
|
|
DocumentationURL: "https://charon.dev/docs/features/webhook-dns",
|
|
IsBuiltIn: false,
|
|
Version: "1.0.0",
|
|
InterfaceVersion: dnsprovider.InterfaceVersion,
|
|
}
|
|
}
|
|
|
|
// Init is called after the plugin is registered.
|
|
func (p *WebhookProvider) Init() error {
|
|
return nil
|
|
}
|
|
|
|
// Cleanup is called before the plugin is unregistered.
|
|
func (p *WebhookProvider) Cleanup() error {
|
|
return nil
|
|
}
|
|
|
|
// RequiredCredentialFields returns credential fields that must be provided.
|
|
func (p *WebhookProvider) RequiredCredentialFields() []dnsprovider.CredentialFieldSpec {
|
|
return []dnsprovider.CredentialFieldSpec{
|
|
{
|
|
Name: "create_url",
|
|
Label: "Create URL",
|
|
Type: "text",
|
|
Placeholder: "https://dns-api.example.com/txt/create",
|
|
Hint: "POST endpoint for creating DNS TXT records. Must be HTTPS (HTTP allowed for localhost in development).",
|
|
},
|
|
{
|
|
Name: "delete_url",
|
|
Label: "Delete URL",
|
|
Type: "text",
|
|
Placeholder: "https://dns-api.example.com/txt/delete",
|
|
Hint: "POST/DELETE endpoint for removing DNS TXT records. Must be HTTPS (HTTP allowed for localhost in development).",
|
|
},
|
|
}
|
|
}
|
|
|
|
// OptionalCredentialFields returns credential fields that may be provided.
|
|
func (p *WebhookProvider) OptionalCredentialFields() []dnsprovider.CredentialFieldSpec {
|
|
return []dnsprovider.CredentialFieldSpec{
|
|
{
|
|
Name: "auth_header",
|
|
Label: "Authorization Header Name",
|
|
Type: "text",
|
|
Placeholder: "Authorization",
|
|
Hint: "Custom header name for authentication (e.g., Authorization, X-API-Key)",
|
|
},
|
|
{
|
|
Name: "auth_value",
|
|
Label: "Authorization Header Value",
|
|
Type: "password",
|
|
Placeholder: "",
|
|
Hint: "Value for the authorization header (e.g., Bearer token, API key)",
|
|
},
|
|
{
|
|
Name: "timeout_seconds",
|
|
Label: "Request Timeout (seconds)",
|
|
Type: "text",
|
|
Placeholder: strconv.Itoa(WebhookDefaultTimeoutSeconds),
|
|
Hint: fmt.Sprintf("HTTP request timeout (%d-%d seconds, default: %d)", WebhookMinTimeoutSeconds, WebhookMaxTimeoutSeconds, WebhookDefaultTimeoutSeconds),
|
|
},
|
|
{
|
|
Name: "retry_count",
|
|
Label: "Retry Count",
|
|
Type: "text",
|
|
Placeholder: strconv.Itoa(WebhookDefaultRetryCount),
|
|
Hint: fmt.Sprintf("Number of retries on failure (%d-%d, default: %d)", WebhookMinRetryCount, WebhookMaxRetryCount, WebhookDefaultRetryCount),
|
|
},
|
|
{
|
|
Name: "insecure_skip_verify",
|
|
Label: "Skip TLS Verification",
|
|
Type: "select",
|
|
Placeholder: "",
|
|
Hint: "⚠️ DEVELOPMENT ONLY: Skip TLS certificate verification. Never enable in production!",
|
|
Options: []dnsprovider.SelectOption{
|
|
{Value: "false", Label: "No (Recommended)"},
|
|
{Value: "true", Label: "Yes (Insecure - Dev Only)"},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// ValidateCredentials checks if the provided credentials are valid.
|
|
func (p *WebhookProvider) ValidateCredentials(creds map[string]string) error {
|
|
// Validate required fields
|
|
createURL := strings.TrimSpace(creds["create_url"])
|
|
if createURL == "" {
|
|
return fmt.Errorf("create_url is required")
|
|
}
|
|
|
|
deleteURL := strings.TrimSpace(creds["delete_url"])
|
|
if deleteURL == "" {
|
|
return fmt.Errorf("delete_url is required")
|
|
}
|
|
|
|
// Validate create URL format and security
|
|
if err := p.validateWebhookURL(createURL, "create_url"); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Validate delete URL format and security
|
|
if err := p.validateWebhookURL(deleteURL, "delete_url"); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Validate timeout if provided
|
|
if timeoutStr := strings.TrimSpace(creds["timeout_seconds"]); timeoutStr != "" {
|
|
timeout, err := strconv.Atoi(timeoutStr)
|
|
if err != nil {
|
|
return fmt.Errorf("timeout_seconds must be a number: %w", err)
|
|
}
|
|
if timeout < WebhookMinTimeoutSeconds || timeout > WebhookMaxTimeoutSeconds {
|
|
return fmt.Errorf("timeout_seconds must be between %d and %d", WebhookMinTimeoutSeconds, WebhookMaxTimeoutSeconds)
|
|
}
|
|
}
|
|
|
|
// Validate retry count if provided
|
|
if retryStr := strings.TrimSpace(creds["retry_count"]); retryStr != "" {
|
|
retry, err := strconv.Atoi(retryStr)
|
|
if err != nil {
|
|
return fmt.Errorf("retry_count must be a number: %w", err)
|
|
}
|
|
if retry < WebhookMinRetryCount || retry > WebhookMaxRetryCount {
|
|
return fmt.Errorf("retry_count must be between %d and %d", WebhookMinRetryCount, WebhookMaxRetryCount)
|
|
}
|
|
}
|
|
|
|
// Validate insecure_skip_verify if provided
|
|
if insecureStr := strings.TrimSpace(creds["insecure_skip_verify"]); insecureStr != "" {
|
|
insecureStr = strings.ToLower(insecureStr)
|
|
if insecureStr != "true" && insecureStr != "false" {
|
|
return fmt.Errorf("insecure_skip_verify must be 'true' or 'false'")
|
|
}
|
|
}
|
|
|
|
// Validate auth header/value consistency
|
|
authHeader := strings.TrimSpace(creds["auth_header"])
|
|
authValue := strings.TrimSpace(creds["auth_value"])
|
|
if (authHeader != "" && authValue == "") || (authHeader == "" && authValue != "") {
|
|
return fmt.Errorf("both auth_header and auth_value must be provided together, or neither")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateWebhookURL validates a webhook URL for format and SSRF protection.
|
|
// Note: During validation, we only check format and basic security constraints.
|
|
// Full SSRF validation with DNS resolution happens at runtime when the webhook is called.
|
|
func (p *WebhookProvider) validateWebhookURL(rawURL, fieldName string) error {
|
|
// Parse URL first for basic validation
|
|
parsed, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
return fmt.Errorf("%s has invalid URL format: %w", fieldName, err)
|
|
}
|
|
|
|
// Validate scheme
|
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
|
return fmt.Errorf("%s must use http or https scheme", fieldName)
|
|
}
|
|
|
|
// Validate hostname exists
|
|
host := parsed.Hostname()
|
|
if host == "" {
|
|
return fmt.Errorf("%s is missing hostname", fieldName)
|
|
}
|
|
|
|
// Check if this is a localhost URL (allowed for development)
|
|
isLocalhost := host == "localhost" || host == "127.0.0.1" || host == "::1"
|
|
|
|
// Require HTTPS for non-localhost URLs
|
|
if !isLocalhost && parsed.Scheme != "https" {
|
|
return fmt.Errorf("%s must use HTTPS for non-localhost URLs (security requirement)", fieldName)
|
|
}
|
|
|
|
// For external URLs (non-localhost), we skip DNS-based SSRF validation during
|
|
// credential validation as the target might not be reachable from the validation
|
|
// environment. Runtime SSRF protection will be enforced when actually calling the webhook.
|
|
// This matches the pattern used by RFC2136Provider which also validates format only.
|
|
|
|
return nil
|
|
}
|
|
|
|
// TestCredentials attempts to verify credentials work.
|
|
// For webhook, we validate the format but cannot test without making actual HTTP calls.
|
|
func (p *WebhookProvider) TestCredentials(creds map[string]string) error {
|
|
return p.ValidateCredentials(creds)
|
|
}
|
|
|
|
// SupportsMultiCredential indicates if the provider can handle zone-specific credentials.
|
|
func (p *WebhookProvider) SupportsMultiCredential() bool {
|
|
return false
|
|
}
|
|
|
|
// BuildCaddyConfig constructs the Caddy DNS challenge configuration.
|
|
// For webhook, this returns a config that Charon's internal webhook handler will use.
|
|
func (p *WebhookProvider) BuildCaddyConfig(creds map[string]string) map[string]any {
|
|
config := map[string]any{
|
|
"name": "webhook",
|
|
"create_url": strings.TrimSpace(creds["create_url"]),
|
|
"delete_url": strings.TrimSpace(creds["delete_url"]),
|
|
}
|
|
|
|
// Add auth header if provided
|
|
if authHeader := strings.TrimSpace(creds["auth_header"]); authHeader != "" {
|
|
config["auth_header"] = authHeader
|
|
}
|
|
|
|
// Add auth value if provided
|
|
if authValue := strings.TrimSpace(creds["auth_value"]); authValue != "" {
|
|
config["auth_value"] = authValue
|
|
}
|
|
|
|
// Add timeout with default
|
|
timeoutSeconds := WebhookDefaultTimeoutSeconds
|
|
if timeoutStr := strings.TrimSpace(creds["timeout_seconds"]); timeoutStr != "" {
|
|
if t, err := strconv.Atoi(timeoutStr); err == nil && t >= WebhookMinTimeoutSeconds && t <= WebhookMaxTimeoutSeconds {
|
|
timeoutSeconds = t
|
|
}
|
|
}
|
|
config["timeout_seconds"] = timeoutSeconds
|
|
|
|
// Add retry count with default
|
|
retryCount := WebhookDefaultRetryCount
|
|
if retryStr := strings.TrimSpace(creds["retry_count"]); retryStr != "" {
|
|
if r, err := strconv.Atoi(retryStr); err == nil && r >= WebhookMinRetryCount && r <= WebhookMaxRetryCount {
|
|
retryCount = r
|
|
}
|
|
}
|
|
config["retry_count"] = retryCount
|
|
|
|
// Add insecure skip verify with default (false)
|
|
insecureSkipVerify := false
|
|
if insecureStr := strings.TrimSpace(creds["insecure_skip_verify"]); insecureStr != "" {
|
|
insecureSkipVerify = strings.ToLower(insecureStr) == "true"
|
|
}
|
|
config["insecure_skip_verify"] = insecureSkipVerify
|
|
|
|
return config
|
|
}
|
|
|
|
// BuildCaddyConfigForZone constructs config for a specific zone.
|
|
func (p *WebhookProvider) BuildCaddyConfigForZone(baseDomain string, creds map[string]string) map[string]any {
|
|
return p.BuildCaddyConfig(creds)
|
|
}
|
|
|
|
// PropagationTimeout returns the recommended DNS propagation wait time.
|
|
func (p *WebhookProvider) PropagationTimeout() time.Duration {
|
|
return p.propagationTimeout
|
|
}
|
|
|
|
// PollingInterval returns the recommended polling interval for DNS verification.
|
|
func (p *WebhookProvider) PollingInterval() time.Duration {
|
|
return p.pollingInterval
|
|
}
|
|
|
|
// GetTimeoutSeconds returns the configured timeout in seconds or the default.
|
|
func (p *WebhookProvider) GetTimeoutSeconds(creds map[string]string) int {
|
|
if timeoutStr := strings.TrimSpace(creds["timeout_seconds"]); timeoutStr != "" {
|
|
if timeout, err := strconv.Atoi(timeoutStr); err == nil {
|
|
if timeout >= WebhookMinTimeoutSeconds && timeout <= WebhookMaxTimeoutSeconds {
|
|
return timeout
|
|
}
|
|
}
|
|
}
|
|
return WebhookDefaultTimeoutSeconds
|
|
}
|
|
|
|
// GetRetryCount returns the configured retry count or the default.
|
|
func (p *WebhookProvider) GetRetryCount(creds map[string]string) int {
|
|
if retryStr := strings.TrimSpace(creds["retry_count"]); retryStr != "" {
|
|
if retry, err := strconv.Atoi(retryStr); err == nil {
|
|
if retry >= WebhookMinRetryCount && retry <= WebhookMaxRetryCount {
|
|
return retry
|
|
}
|
|
}
|
|
}
|
|
return WebhookDefaultRetryCount
|
|
}
|
|
|
|
// IsInsecureSkipVerify returns whether TLS verification should be skipped.
|
|
func (p *WebhookProvider) IsInsecureSkipVerify(creds map[string]string) bool {
|
|
if insecureStr := strings.TrimSpace(creds["insecure_skip_verify"]); insecureStr != "" {
|
|
return strings.ToLower(insecureStr) == "true"
|
|
}
|
|
return false
|
|
}
|