- Created a comprehensive documentation file for DNS provider types, including RFC 2136, Webhook, and Script providers, detailing their use cases, configurations, and security notes. - Updated the DNSProviderForm component to handle new field types including select and textarea for better user input management. - Enhanced the DNS provider schemas to include new fields for script execution, webhook authentication, and RFC 2136 configurations, improving flexibility and usability.
312 lines
11 KiB
Go
312 lines
11 KiB
Go
// Package custom provides custom DNS provider plugins for non-built-in integrations.
|
|
package custom
|
|
|
|
import (
|
|
"fmt"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Wikid82/charon/backend/pkg/dnsprovider"
|
|
)
|
|
|
|
// Script provider constants.
|
|
const (
|
|
ScriptDefaultTimeoutSeconds = 60
|
|
ScriptDefaultPropagationTimeout = 120 * time.Second
|
|
ScriptDefaultPollingInterval = 5 * time.Second
|
|
ScriptMinTimeoutSeconds = 5
|
|
ScriptMaxTimeoutSeconds = 300
|
|
ScriptAllowedDirectory = "/scripts/"
|
|
)
|
|
|
|
// scriptArgPattern validates script arguments to prevent injection attacks.
|
|
// Only allows alphanumeric characters, dots, underscores, equals, and hyphens.
|
|
var scriptArgPattern = regexp.MustCompile(`^[a-zA-Z0-9._=-]+$`)
|
|
|
|
// envVarLinePattern validates environment variable format (KEY=VALUE).
|
|
var envVarLinePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*=.*$`)
|
|
|
|
// ScriptProvider implements the ProviderPlugin interface for shell script DNS challenges.
|
|
// This provider executes local scripts to create/delete DNS TXT records.
|
|
//
|
|
// SECURITY WARNING: This is a HIGH-RISK feature. Scripts are executed on the server
|
|
// with the same privileges as the Charon process. Only administrators should configure
|
|
// this provider, and scripts must be carefully reviewed before deployment.
|
|
type ScriptProvider struct {
|
|
propagationTimeout time.Duration
|
|
pollingInterval time.Duration
|
|
}
|
|
|
|
// NewScriptProvider creates a new ScriptProvider with default settings.
|
|
func NewScriptProvider() *ScriptProvider {
|
|
return &ScriptProvider{
|
|
propagationTimeout: ScriptDefaultPropagationTimeout,
|
|
pollingInterval: ScriptDefaultPollingInterval,
|
|
}
|
|
}
|
|
|
|
// Type returns the unique provider type identifier.
|
|
func (p *ScriptProvider) Type() string {
|
|
return "script"
|
|
}
|
|
|
|
// Metadata returns descriptive information about the provider.
|
|
func (p *ScriptProvider) Metadata() dnsprovider.ProviderMetadata {
|
|
return dnsprovider.ProviderMetadata{
|
|
Type: "script",
|
|
Name: "Script (Shell)",
|
|
Description: "⚠️ ADVANCED: Execute shell scripts for DNS challenges. Scripts must be located in /scripts/. HIGH-RISK feature - scripts run with server privileges. Only for administrators with custom DNS infrastructure.",
|
|
DocumentationURL: "https://charon.dev/docs/features/script-dns",
|
|
IsBuiltIn: false,
|
|
Version: "1.0.0",
|
|
InterfaceVersion: dnsprovider.InterfaceVersion,
|
|
}
|
|
}
|
|
|
|
// Init is called after the plugin is registered.
|
|
func (p *ScriptProvider) Init() error {
|
|
return nil
|
|
}
|
|
|
|
// Cleanup is called before the plugin is unregistered.
|
|
func (p *ScriptProvider) Cleanup() error {
|
|
return nil
|
|
}
|
|
|
|
// RequiredCredentialFields returns credential fields that must be provided.
|
|
func (p *ScriptProvider) RequiredCredentialFields() []dnsprovider.CredentialFieldSpec {
|
|
return []dnsprovider.CredentialFieldSpec{
|
|
{
|
|
Name: "script_path",
|
|
Label: "Script Path",
|
|
Type: "text",
|
|
Placeholder: "/scripts/dns-challenge.sh",
|
|
Hint: "Path to the DNS challenge script. Must be located in /scripts/ directory. Script receives: action (create/delete), domain, token, and key_auth as arguments.",
|
|
},
|
|
}
|
|
}
|
|
|
|
// OptionalCredentialFields returns credential fields that may be provided.
|
|
func (p *ScriptProvider) OptionalCredentialFields() []dnsprovider.CredentialFieldSpec {
|
|
return []dnsprovider.CredentialFieldSpec{
|
|
{
|
|
Name: "timeout_seconds",
|
|
Label: "Script Timeout (seconds)",
|
|
Type: "text",
|
|
Placeholder: strconv.Itoa(ScriptDefaultTimeoutSeconds),
|
|
Hint: fmt.Sprintf("Maximum execution time for the script (%d-%d seconds, default: %d)", ScriptMinTimeoutSeconds, ScriptMaxTimeoutSeconds, ScriptDefaultTimeoutSeconds),
|
|
},
|
|
{
|
|
Name: "env_vars",
|
|
Label: "Environment Variables",
|
|
Type: "textarea",
|
|
Placeholder: "DNS_API_KEY=your-key\nDNS_API_URL=https://api.example.com",
|
|
Hint: "Optional environment variables passed to the script. One KEY=VALUE pair per line. Keys must start with a letter or underscore.",
|
|
},
|
|
}
|
|
}
|
|
|
|
// ValidateCredentials checks if the provided credentials are valid.
|
|
func (p *ScriptProvider) ValidateCredentials(creds map[string]string) error {
|
|
// Validate required script path
|
|
scriptPath := strings.TrimSpace(creds["script_path"])
|
|
if scriptPath == "" {
|
|
return fmt.Errorf("script_path is required")
|
|
}
|
|
|
|
// Validate script path for security
|
|
if err := validateScriptPath(scriptPath); err != nil {
|
|
return fmt.Errorf("script_path validation failed: %w", 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 < ScriptMinTimeoutSeconds || timeout > ScriptMaxTimeoutSeconds {
|
|
return fmt.Errorf("timeout_seconds must be between %d and %d", ScriptMinTimeoutSeconds, ScriptMaxTimeoutSeconds)
|
|
}
|
|
}
|
|
|
|
// Validate environment variables if provided
|
|
if envVars := strings.TrimSpace(creds["env_vars"]); envVars != "" {
|
|
if err := validateEnvVars(envVars); err != nil {
|
|
return fmt.Errorf("env_vars validation failed: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateScriptPath validates a script path for security.
|
|
// SECURITY: This function is critical for preventing path traversal attacks.
|
|
func validateScriptPath(scriptPath string) error {
|
|
// Clean the path first to normalize it
|
|
// SECURITY: filepath.Clean resolves ".." sequences, so "/scripts/../etc/passwd"
|
|
// becomes "/etc/passwd" - the directory check below will then reject it.
|
|
cleaned := filepath.Clean(scriptPath)
|
|
|
|
// SECURITY: Must start with the allowed directory
|
|
// This check catches path traversal because filepath.Clean already resolved ".."
|
|
if !strings.HasPrefix(cleaned, ScriptAllowedDirectory) {
|
|
return fmt.Errorf("script must be in %s directory, got: %s", ScriptAllowedDirectory, cleaned)
|
|
}
|
|
|
|
// SECURITY: Validate the path doesn't contain null bytes (common injection vector)
|
|
if strings.ContainsRune(scriptPath, '\x00') {
|
|
return fmt.Errorf("path contains invalid characters")
|
|
}
|
|
|
|
// SECURITY: Validate the filename portion doesn't start with a hyphen
|
|
// (to prevent argument injection in shell commands)
|
|
base := filepath.Base(cleaned)
|
|
if strings.HasPrefix(base, "-") {
|
|
return fmt.Errorf("script filename cannot start with hyphen")
|
|
}
|
|
|
|
// SECURITY: Validate the script name matches safe pattern
|
|
if !scriptArgPattern.MatchString(base) {
|
|
return fmt.Errorf("script filename contains invalid characters: only alphanumeric, dots, underscores, equals, and hyphens allowed")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateEnvVars validates environment variable format.
|
|
func validateEnvVars(envVars string) error {
|
|
lines := strings.Split(envVars, "\n")
|
|
|
|
for i, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue // Skip empty lines
|
|
}
|
|
|
|
// Validate format: KEY=VALUE
|
|
if !strings.Contains(line, "=") {
|
|
return fmt.Errorf("line %d: invalid format, expected KEY=VALUE", i+1)
|
|
}
|
|
|
|
// Validate the line matches the pattern
|
|
if !envVarLinePattern.MatchString(line) {
|
|
return fmt.Errorf("line %d: invalid environment variable format, key must start with letter or underscore", i+1)
|
|
}
|
|
|
|
// Extract and validate key
|
|
parts := strings.SplitN(line, "=", 2)
|
|
key := parts[0]
|
|
|
|
// SECURITY: Prevent overriding critical environment variables
|
|
criticalVars := []string{"PATH", "LD_PRELOAD", "LD_LIBRARY_PATH", "HOME", "USER", "SHELL"}
|
|
for _, critical := range criticalVars {
|
|
if strings.EqualFold(key, critical) {
|
|
return fmt.Errorf("line %d: cannot override critical environment variable %q", i+1, key)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// parseEnvVars parses environment variable string into a map.
|
|
func parseEnvVars(envVars string) map[string]string {
|
|
result := make(map[string]string)
|
|
|
|
if envVars == "" {
|
|
return result
|
|
}
|
|
|
|
lines := strings.Split(envVars, "\n")
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
parts := strings.SplitN(line, "=", 2)
|
|
if len(parts) == 2 {
|
|
result[parts[0]] = parts[1]
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// TestCredentials attempts to verify credentials work.
|
|
// For script provider, we validate the format but cannot test without executing the script.
|
|
func (p *ScriptProvider) TestCredentials(creds map[string]string) error {
|
|
return p.ValidateCredentials(creds)
|
|
}
|
|
|
|
// SupportsMultiCredential indicates if the provider can handle zone-specific credentials.
|
|
func (p *ScriptProvider) SupportsMultiCredential() bool {
|
|
return false
|
|
}
|
|
|
|
// BuildCaddyConfig constructs the Caddy DNS challenge configuration.
|
|
// For script, this returns a config that Charon's internal script handler will use.
|
|
func (p *ScriptProvider) BuildCaddyConfig(creds map[string]string) map[string]any {
|
|
scriptPath := strings.TrimSpace(creds["script_path"])
|
|
|
|
config := map[string]any{
|
|
"name": "script",
|
|
"script_path": filepath.Clean(scriptPath),
|
|
}
|
|
|
|
// Add timeout with default
|
|
timeoutSeconds := ScriptDefaultTimeoutSeconds
|
|
if timeoutStr := strings.TrimSpace(creds["timeout_seconds"]); timeoutStr != "" {
|
|
if t, err := strconv.Atoi(timeoutStr); err == nil && t >= ScriptMinTimeoutSeconds && t <= ScriptMaxTimeoutSeconds {
|
|
timeoutSeconds = t
|
|
}
|
|
}
|
|
config["timeout_seconds"] = timeoutSeconds
|
|
|
|
// Add environment variables if provided
|
|
if envVars := strings.TrimSpace(creds["env_vars"]); envVars != "" {
|
|
config["env_vars"] = parseEnvVars(envVars)
|
|
} else {
|
|
config["env_vars"] = map[string]string{}
|
|
}
|
|
|
|
return config
|
|
}
|
|
|
|
// BuildCaddyConfigForZone constructs config for a specific zone.
|
|
func (p *ScriptProvider) BuildCaddyConfigForZone(baseDomain string, creds map[string]string) map[string]any {
|
|
return p.BuildCaddyConfig(creds)
|
|
}
|
|
|
|
// PropagationTimeout returns the recommended DNS propagation wait time.
|
|
func (p *ScriptProvider) PropagationTimeout() time.Duration {
|
|
return p.propagationTimeout
|
|
}
|
|
|
|
// PollingInterval returns the recommended polling interval for DNS verification.
|
|
func (p *ScriptProvider) PollingInterval() time.Duration {
|
|
return p.pollingInterval
|
|
}
|
|
|
|
// GetTimeoutSeconds returns the configured timeout in seconds or the default.
|
|
func (p *ScriptProvider) GetTimeoutSeconds(creds map[string]string) int {
|
|
if timeoutStr := strings.TrimSpace(creds["timeout_seconds"]); timeoutStr != "" {
|
|
if timeout, err := strconv.Atoi(timeoutStr); err == nil {
|
|
if timeout >= ScriptMinTimeoutSeconds && timeout <= ScriptMaxTimeoutSeconds {
|
|
return timeout
|
|
}
|
|
}
|
|
}
|
|
return ScriptDefaultTimeoutSeconds
|
|
}
|
|
|
|
// GetEnvVars returns the parsed environment variables from credentials.
|
|
func (p *ScriptProvider) GetEnvVars(creds map[string]string) map[string]string {
|
|
envVars := strings.TrimSpace(creds["env_vars"])
|
|
return parseEnvVars(envVars)
|
|
}
|