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)
|
|
}
|