package installers import ( "fmt" "io" "os" "os/exec" "path/filepath" ) const ( caddyVersion = "2.10.2" xcaddyRepo = "github.com/caddyserver/xcaddy/cmd/xcaddy@latest" ) // CaddyInstaller handles Caddy installation with custom DNS module type CaddyInstaller struct { *BaseInstaller version string oramaHome string dnsModule string // Path to the orama DNS module source } // NewCaddyInstaller creates a new Caddy installer func NewCaddyInstaller(arch string, logWriter io.Writer, oramaHome string) *CaddyInstaller { return &CaddyInstaller{ BaseInstaller: NewBaseInstaller(arch, logWriter), version: caddyVersion, oramaHome: oramaHome, dnsModule: filepath.Join(oramaHome, "src", "pkg", "caddy", "dns", "orama"), } } // IsInstalled checks if Caddy with orama DNS module is already installed func (ci *CaddyInstaller) IsInstalled() bool { caddyPath := "/usr/bin/caddy" if _, err := os.Stat(caddyPath); os.IsNotExist(err) { return false } // Verify it has the orama DNS module cmd := exec.Command(caddyPath, "list-modules") output, err := cmd.Output() if err != nil { return false } return containsLine(string(output), "dns.providers.orama") } // Install builds and installs Caddy with the custom orama DNS module func (ci *CaddyInstaller) Install() error { if ci.IsInstalled() { fmt.Fprintf(ci.logWriter, " ✓ Caddy with orama DNS module already installed\n") return nil } fmt.Fprintf(ci.logWriter, " Building Caddy with orama DNS module...\n") // Check if Go is available if _, err := exec.LookPath("go"); err != nil { return fmt.Errorf("go not found - required to build Caddy. Please install Go first") } goPath := os.Getenv("PATH") + ":/usr/local/go/bin" buildDir := "/tmp/caddy-build" // Clean up any previous build os.RemoveAll(buildDir) if err := os.MkdirAll(buildDir, 0755); err != nil { return fmt.Errorf("failed to create build directory: %w", err) } defer os.RemoveAll(buildDir) // Install xcaddy if not available if _, err := exec.LookPath("xcaddy"); err != nil { fmt.Fprintf(ci.logWriter, " Installing xcaddy...\n") cmd := exec.Command("go", "install", xcaddyRepo) cmd.Env = append(os.Environ(), "PATH="+goPath, "GOBIN=/usr/local/bin") if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("failed to install xcaddy: %w\n%s", err, string(output)) } } // Create the orama DNS module in build directory fmt.Fprintf(ci.logWriter, " Creating orama DNS module...\n") moduleDir := filepath.Join(buildDir, "caddy-dns-orama") if err := os.MkdirAll(moduleDir, 0755); err != nil { return fmt.Errorf("failed to create module directory: %w", err) } // Write the provider.go file providerCode := ci.generateProviderCode() if err := os.WriteFile(filepath.Join(moduleDir, "provider.go"), []byte(providerCode), 0644); err != nil { return fmt.Errorf("failed to write provider.go: %w", err) } // Write go.mod goMod := ci.generateGoMod() if err := os.WriteFile(filepath.Join(moduleDir, "go.mod"), []byte(goMod), 0644); err != nil { return fmt.Errorf("failed to write go.mod: %w", err) } // Run go mod tidy tidyCmd := exec.Command("go", "mod", "tidy") tidyCmd.Dir = moduleDir tidyCmd.Env = append(os.Environ(), "PATH="+goPath) if output, err := tidyCmd.CombinedOutput(); err != nil { return fmt.Errorf("failed to run go mod tidy: %w\n%s", err, string(output)) } // Build Caddy with xcaddy fmt.Fprintf(ci.logWriter, " Building Caddy binary...\n") xcaddyPath := "/usr/local/bin/xcaddy" if _, err := os.Stat(xcaddyPath); os.IsNotExist(err) { xcaddyPath = "xcaddy" // Try PATH } buildCmd := exec.Command(xcaddyPath, "build", "v"+ci.version, "--with", "github.com/DeBrosOfficial/caddy-dns-orama="+moduleDir, "--output", filepath.Join(buildDir, "caddy")) buildCmd.Dir = buildDir buildCmd.Env = append(os.Environ(), "PATH="+goPath) if output, err := buildCmd.CombinedOutput(); err != nil { return fmt.Errorf("failed to build Caddy: %w\n%s", err, string(output)) } // Verify the binary has orama DNS module verifyCmd := exec.Command(filepath.Join(buildDir, "caddy"), "list-modules") output, err := verifyCmd.Output() if err != nil { return fmt.Errorf("failed to verify Caddy binary: %w", err) } if !containsLine(string(output), "dns.providers.orama") { return fmt.Errorf("Caddy binary does not contain orama DNS module") } // Install the binary fmt.Fprintf(ci.logWriter, " Installing Caddy binary...\n") srcBinary := filepath.Join(buildDir, "caddy") dstBinary := "/usr/bin/caddy" data, err := os.ReadFile(srcBinary) if err != nil { return fmt.Errorf("failed to read built binary: %w", err) } if err := os.WriteFile(dstBinary, data, 0755); err != nil { return fmt.Errorf("failed to install binary: %w", err) } // Grant CAP_NET_BIND_SERVICE to allow binding to ports 80/443 if err := exec.Command("setcap", "cap_net_bind_service=+ep", dstBinary).Run(); err != nil { fmt.Fprintf(ci.logWriter, " ⚠️ Warning: failed to setcap on caddy: %v\n", err) } fmt.Fprintf(ci.logWriter, " ✓ Caddy with orama DNS module installed\n") return nil } // Configure creates Caddy configuration files func (ci *CaddyInstaller) Configure(domain string, email string, acmeEndpoint string) error { configDir := "/etc/caddy" if err := os.MkdirAll(configDir, 0755); err != nil { return fmt.Errorf("failed to create config directory: %w", err) } // Create Caddyfile caddyfile := ci.generateCaddyfile(domain, email, acmeEndpoint) if err := os.WriteFile(filepath.Join(configDir, "Caddyfile"), []byte(caddyfile), 0644); err != nil { return fmt.Errorf("failed to write Caddyfile: %w", err) } return nil } // generateProviderCode creates the orama DNS provider code func (ci *CaddyInstaller) generateProviderCode() string { return `// Package orama implements a DNS provider for Caddy that uses the Orama Network // gateway's internal ACME API for DNS-01 challenge validation. package orama import ( "bytes" "context" "encoding/json" "fmt" "net/http" "time" "github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" "github.com/libdns/libdns" ) func init() { caddy.RegisterModule(Provider{}) } // Provider wraps the Orama DNS provider for Caddy. type Provider struct { // Endpoint is the URL of the Orama gateway's ACME API // Default: http://localhost:6001/v1/internal/acme Endpoint string ` + "`json:\"endpoint,omitempty\"`" + ` } // CaddyModule returns the Caddy module information. func (Provider) CaddyModule() caddy.ModuleInfo { return caddy.ModuleInfo{ ID: "dns.providers.orama", New: func() caddy.Module { return new(Provider) }, } } // Provision sets up the module. func (p *Provider) Provision(ctx caddy.Context) error { if p.Endpoint == "" { p.Endpoint = "http://localhost:6001/v1/internal/acme" } return nil } // UnmarshalCaddyfile parses the Caddyfile configuration. func (p *Provider) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { for d.Next() { for d.NextBlock(0) { switch d.Val() { case "endpoint": if !d.NextArg() { return d.ArgErr() } p.Endpoint = d.Val() default: return d.Errf("unrecognized option: %s", d.Val()) } } } return nil } // AppendRecords adds records to the zone. For ACME, this presents the challenge. func (p *Provider) AppendRecords(ctx context.Context, zone string, records []libdns.Record) ([]libdns.Record, error) { var added []libdns.Record for _, rec := range records { rr := rec.RR() if rr.Type != "TXT" { continue } fqdn := rr.Name + "." + zone payload := map[string]string{ "fqdn": fqdn, "value": rr.Data, } body, err := json.Marshal(payload) if err != nil { return added, fmt.Errorf("failed to marshal request: %w", err) } req, err := http.NewRequestWithContext(ctx, "POST", p.Endpoint+"/present", bytes.NewReader(body)) if err != nil { return added, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { return added, fmt.Errorf("failed to present challenge: %w", err) } resp.Body.Close() if resp.StatusCode != http.StatusOK { return added, fmt.Errorf("present failed with status %d", resp.StatusCode) } added = append(added, rec) } return added, nil } // DeleteRecords removes records from the zone. For ACME, this cleans up the challenge. func (p *Provider) DeleteRecords(ctx context.Context, zone string, records []libdns.Record) ([]libdns.Record, error) { var deleted []libdns.Record for _, rec := range records { rr := rec.RR() if rr.Type != "TXT" { continue } fqdn := rr.Name + "." + zone payload := map[string]string{ "fqdn": fqdn, "value": rr.Data, } body, err := json.Marshal(payload) if err != nil { return deleted, fmt.Errorf("failed to marshal request: %w", err) } req, err := http.NewRequestWithContext(ctx, "POST", p.Endpoint+"/cleanup", bytes.NewReader(body)) if err != nil { return deleted, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { return deleted, fmt.Errorf("failed to cleanup challenge: %w", err) } resp.Body.Close() if resp.StatusCode != http.StatusOK { return deleted, fmt.Errorf("cleanup failed with status %d", resp.StatusCode) } deleted = append(deleted, rec) } return deleted, nil } // GetRecords returns the records in the zone. Not used for ACME. func (p *Provider) GetRecords(ctx context.Context, zone string) ([]libdns.Record, error) { return nil, nil } // SetRecords sets the records in the zone. Not used for ACME. func (p *Provider) SetRecords(ctx context.Context, zone string, records []libdns.Record) ([]libdns.Record, error) { return nil, nil } // Interface guards var ( _ caddy.Module = (*Provider)(nil) _ caddy.Provisioner = (*Provider)(nil) _ caddyfile.Unmarshaler = (*Provider)(nil) _ libdns.RecordAppender = (*Provider)(nil) _ libdns.RecordDeleter = (*Provider)(nil) _ libdns.RecordGetter = (*Provider)(nil) _ libdns.RecordSetter = (*Provider)(nil) ) ` } // generateGoMod creates the go.mod file for the module func (ci *CaddyInstaller) generateGoMod() string { return `module github.com/DeBrosOfficial/caddy-dns-orama go 1.22 require ( github.com/caddyserver/caddy/v2 v2.` + caddyVersion[2:] + ` github.com/libdns/libdns v1.1.0 ) ` } // generateCaddyfile creates the Caddyfile configuration func (ci *CaddyInstaller) generateCaddyfile(domain, email, acmeEndpoint string) string { return fmt.Sprintf(`{ email %s } *.%s { tls { dns orama { endpoint %s } } reverse_proxy localhost:6001 } :443 { tls { dns orama { endpoint %s } } reverse_proxy localhost:6001 } :80 { reverse_proxy localhost:6001 } `, email, domain, acmeEndpoint, acmeEndpoint) }