Skip to content

ACME Certificate Management

The server supports automatic TLS certificate management via ACME protocol (e.g., Let's Encrypt).

Enabling ACME

Add an [acme] section to your server configuration:

[acme]
enabled = true
directory_url = "https://acme-v02.api.letsencrypt.org/directory"  # Or staging URL for testing
email = "admin@example.com"
domains = ["router.example.com", "api.example.com"]
challenge_type = "http-01"  # or "dns-01" for wildcard certs and internal servers

[acme.renewal]
days_before_expiry = 30     # Renew when cert expires within 30 days
jitter_minutes = 60         # Random delay to prevent thundering herd

Challenge Types

HTTP-01

Best for publicly accessible servers:

  • Requires port 80 accessible from the internet
  • Simpler setup, no DNS API access needed
  • Cannot issue wildcard certificates

DNS-01

Best for internal servers and wildcards:

  • Works behind firewalls
  • Supports wildcard certificates (*.example.com)
  • Requires DNS provider API access

DNS-01 Challenge Configuration

For DNS-01 challenges, configure one DNS provider. Supports Cloudflare (built-in) or any DNS API via webhook.

Cloudflare Provider

[acme]
enabled = true
directory_url = "https://acme-v02.api.letsencrypt.org/directory"
email = "admin@example.com"
domains = ["*.example.com", "example.com"]  # Wildcard requires DNS-01
challenge_type = "dns-01"

[acme.dns.cloudflare]
api_token = "${CF_API_TOKEN}"  # Token with Zone:DNS:Edit permission
zone_id = "abc123"             # Optional - auto-detected from domain if omitted

Webhook Provider (generic)

For DNS providers without built-in support:

[acme]
enabled = true
directory_url = "https://acme-v02.api.letsencrypt.org/directory"
email = "admin@example.com"
domains = ["internal.example.com"]
challenge_type = "dns-01"

[acme.dns.webhook]
# POST to create TXT record, expects {"id": "record-id"} response
create_url = "https://dns-api.example.com/records"
# DELETE to remove record, {record_id} replaced with ID from create response
delete_url = "https://dns-api.example.com/records/{record_id}"
timeout_seconds = 30  # Request timeout (propagation delay is fixed at 120s)

[acme.dns.webhook.headers]
Authorization = "Bearer ${DNS_API_TOKEN}"

Environment Variable Expansion

Sensitive values can reference environment variables:

[acme.dns.cloudflare]
api_token = "${CF_API_TOKEN}"                    # Required
zone_id = "${CF_ZONE_ID:-auto}"                  # With default

Supported syntax:

  • ${VAR} - Required variable (fails if unset/empty)
  • ${VAR:-default} - Use default if unset/empty
  • $$ - Literal dollar sign

File Locations

File Path Permissions
Certificate Configured TLS cert path 0644
Private Key Configured TLS key path 0600
Account Credentials <data_dir>/acme-account.json 0600

Windows Security Note

On Windows, Unix-style file permissions (0600) cannot be set. Credential files inherit permissions from the parent directory's ACL. Operators must ensure the credentials directory is only accessible by the service account. A warning is logged when writing credentials on non-Unix platforms.

Platform-Specific Defaults

The default credentials_path (/var/lib/router-hosts/acme-account.json) is Unix-specific. On Windows, you must explicitly set this to a valid path:

[acme]
credentials_path = "C:\\ProgramData\\router-hosts\\acme-account.json"

Testing

ACME logic is covered by unit tests with mocked ACME server responses.

Running tests:

go test ./internal/acme/... -v

Troubleshooting

HTTP-01 Challenge Issues

Certificate renewal fails repeatedly:

  1. Verify DNS records point to this server
  2. Ensure port 80 is accessible from the internet
  3. Check Let's Encrypt rate limits: https://letsencrypt.org/docs/rate-limits/
  4. Review server logs for detailed error messages

DNS-01 Challenge Issues

"Zone not found" error:

  • Verify the domain matches a zone in your DNS provider account
  • For Cloudflare: check the zone exists in your account dashboard
  • If using subdomains, the parent zone must exist (e.g., sub.example.com requires example.com zone)
  • Consider explicitly configuring zone_id instead of relying on auto-detection

"API token invalid" or authentication errors:

  • Cloudflare: Verify token has Zone:DNS:Edit permission
  • Cloudflare: Ensure token is scoped to the correct zone
  • Check token hasn't expired or been revoked
  • Verify environment variable expansion is working: echo $CF_API_TOKEN

"DNS record creation timed out" error:

  • Check DNS provider API status page for outages
  • Verify network connectivity to DNS provider API
  • For Cloudflare: check you haven't hit API rate limits (1200 requests/5 min)
  • Try increasing DNS_OPERATION_TIMEOUT if on slow network

Challenge validation fails after record creation:

  • Increase propagation delay in config (default: 10s for Cloudflare, 120s for webhook)
  • Use dig _acme-challenge.yourdomain.com TXT to verify record is visible
  • Some DNS providers have longer propagation times

Stale TXT records after failed renewal:

  • If renewal crashes, _acme-challenge.* TXT records may remain
  • Manually delete via DNS provider dashboard or API
  • These don't affect functionality but clutter your DNS zone

General Issues

Rate limit errors:

  • Let's Encrypt allows 5 certificates per domain per week
  • Use staging URL for testing: https://acme-staging-v02.api.letsencrypt.org/directory
  • Wait 1 week for rate limits to reset

Account credential backup:

  • The acme-account.json file contains your ACME account private key
  • Back up this file to avoid needing to re-register with Let's Encrypt
  • File is written atomically with 0600 permissions