orama/pkg/namespace/turn_cert.go

166 lines
4.9 KiB
Go

package namespace
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
const (
caddyfilePath = "/etc/caddy/Caddyfile"
// Caddy stores ACME certs under this directory relative to its data dir.
caddyACMECertDir = "certificates/acme-v02.api.letsencrypt.org-directory"
turnCertBeginMarker = "# BEGIN TURN CERT: "
turnCertEndMarker = "# END TURN CERT: "
)
// provisionTURNCertViaCaddy appends the TURN domain to the local Caddyfile,
// reloads Caddy to trigger DNS-01 ACME certificate provisioning, and waits
// for the cert files to appear. Returns the cert/key paths on success.
// If Caddy is not available or cert provisioning times out, returns an error
// so the caller can fall back to a self-signed cert.
func provisionTURNCertViaCaddy(domain, acmeEndpoint string, timeout time.Duration) (certPath, keyPath string, err error) {
// Check if cert already exists from a previous provisioning
certPath, keyPath = caddyCertPaths(domain)
if _, err := os.Stat(certPath); err == nil {
return certPath, keyPath, nil
}
// Read current Caddyfile
data, err := os.ReadFile(caddyfilePath)
if err != nil {
return "", "", fmt.Errorf("failed to read Caddyfile: %w", err)
}
caddyfile := string(data)
// Check if domain block already exists (idempotent)
marker := turnCertBeginMarker + domain
if strings.Contains(caddyfile, marker) {
// Block already present — just wait for cert
return waitForCaddyCert(domain, timeout)
}
// Append a minimal Caddyfile block for the TURN domain
block := fmt.Sprintf(`
%s%s
%s {
tls {
issuer acme {
dns orama {
endpoint %s
}
}
}
respond "OK" 200
}
%s%s
`, turnCertBeginMarker, domain, domain, acmeEndpoint, turnCertEndMarker, domain)
if err := os.WriteFile(caddyfilePath, []byte(caddyfile+block), 0644); err != nil {
return "", "", fmt.Errorf("failed to write Caddyfile: %w", err)
}
// Reload Caddy to pick up the new domain
if err := reloadCaddy(); err != nil {
return "", "", fmt.Errorf("failed to reload Caddy: %w", err)
}
// Wait for cert to be provisioned
return waitForCaddyCert(domain, timeout)
}
// removeTURNCertFromCaddy removes the TURN domain block from the Caddyfile
// and reloads Caddy. Safe to call even if the block doesn't exist.
func removeTURNCertFromCaddy(domain string) error {
data, err := os.ReadFile(caddyfilePath)
if err != nil {
return fmt.Errorf("failed to read Caddyfile: %w", err)
}
caddyfile := string(data)
beginMarker := turnCertBeginMarker + domain
endMarker := turnCertEndMarker + domain
beginIdx := strings.Index(caddyfile, beginMarker)
if beginIdx == -1 {
return nil // Block not found, nothing to remove
}
endIdx := strings.Index(caddyfile, endMarker)
if endIdx == -1 {
return nil // Malformed markers, skip
}
// Include the end marker line itself
endIdx += len(endMarker)
// Also consume the trailing newline if present
if endIdx < len(caddyfile) && caddyfile[endIdx] == '\n' {
endIdx++
}
// Remove leading newline before the begin marker if present
if beginIdx > 0 && caddyfile[beginIdx-1] == '\n' {
beginIdx--
}
newCaddyfile := caddyfile[:beginIdx] + caddyfile[endIdx:]
if err := os.WriteFile(caddyfilePath, []byte(newCaddyfile), 0644); err != nil {
return fmt.Errorf("failed to write Caddyfile: %w", err)
}
return reloadCaddy()
}
// caddyCertPaths returns the expected cert and key file paths in Caddy's
// storage for a given domain. Caddy stores ACME certs as standard PEM files.
func caddyCertPaths(domain string) (certPath, keyPath string) {
dataDir := caddyDataDir()
certDir := filepath.Join(dataDir, caddyACMECertDir, domain)
return filepath.Join(certDir, domain+".crt"), filepath.Join(certDir, domain+".key")
}
// caddyDataDir returns Caddy's data directory. Caddy uses XDG_DATA_HOME/caddy
// if set, otherwise falls back to $HOME/.local/share/caddy.
func caddyDataDir() string {
if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
return filepath.Join(xdg, "caddy")
}
home := os.Getenv("HOME")
if home == "" {
home = "/root" // Caddy runs as root in our setup
}
return filepath.Join(home, ".local", "share", "caddy")
}
// waitForCaddyCert polls for the cert file to appear with a timeout.
func waitForCaddyCert(domain string, timeout time.Duration) (string, string, error) {
certPath, keyPath := caddyCertPaths(domain)
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if _, err := os.Stat(certPath); err == nil {
if _, err := os.Stat(keyPath); err == nil {
return certPath, keyPath, nil
}
}
time.Sleep(5 * time.Second)
}
return "", "", fmt.Errorf("timed out waiting for Caddy to provision cert for %s (checked %s)", domain, certPath)
}
// reloadCaddy sends a reload signal to Caddy via systemctl.
func reloadCaddy() error {
cmd := exec.Command("systemctl", "reload", "caddy")
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("systemctl reload caddy failed: %w (%s)", err, strings.TrimSpace(string(output)))
}
return nil
}