Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 13 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1088,16 +1088,18 @@ needs access to your DNS provider's API.

**Supported DNS Providers:**

| Provider | Environment Variables |
|----------|----------------------|
| Cloudflare | `CF_API_TOKEN` or (`CF_API_KEY` + `CF_API_EMAIL`) |
| AWS Route53 | `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` |
| DigitalOcean | `DO_AUTH_TOKEN` |
| Google Cloud DNS | `GCE_PROJECT` + `GOOGLE_APPLICATION_CREDENTIALS` |
| Namecheap | `NAMECHEAP_API_USER` + `NAMECHEAP_API_KEY` |
| GoDaddy | `GODADDY_API_KEY` + `GODADDY_API_SECRET` |
| Hetzner | `HETZNER_API_KEY` |
| Vultr | `VULTR_API_KEY` |
<!-- BEGIN GENERATED: dns-provider-table (go generate ./internal/server/acme/providers) -->
| Name | Provider | Credentials | Optional |
|------|----------|-------------|----------|
| `cloudflare` | [Cloudflare](https://go-acme.github.io/lego/dns/cloudflare/) | `CF_API_TOKEN` or `CF_DNS_API_TOKEN` or (`CF_API_KEY` + `CF_API_EMAIL`) | `CF_API_EMAIL`, `CF_API_KEY`, `CF_DNS_API_TOKEN`, `CF_ZONE_API_TOKEN` |
| `digitalocean` | [DigitalOcean](https://go-acme.github.io/lego/dns/digitalocean/) | `DO_AUTH_TOKEN` | — |
| `gcloud` | [Google Cloud DNS](https://go-acme.github.io/lego/dns/gcloud/) | `GCE_PROJECT` | `GCE_SERVICE_ACCOUNT_FILE`, `GOOGLE_APPLICATION_CREDENTIALS` |
| `godaddy` | [GoDaddy](https://go-acme.github.io/lego/dns/godaddy/) | (`GODADDY_API_KEY` + `GODADDY_API_SECRET`) | — |
| `hetzner` | [Hetzner](https://go-acme.github.io/lego/dns/hetzner/) | `HETZNER_API_KEY` | — |
| `namecheap` | [Namecheap](https://go-acme.github.io/lego/dns/namecheap/) | (`NAMECHEAP_API_USER` + `NAMECHEAP_API_KEY`) | `NAMECHEAP_SANDBOX` |
| `route53` | [AWS Route53](https://go-acme.github.io/lego/dns/route53/) | (`AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY`) | `AWS_REGION`, `AWS_HOSTED_ZONE_ID`, `AWS_PROFILE` |
| `vultr` | [Vultr](https://go-acme.github.io/lego/dns/vultr/) | `VULTR_API_KEY` | — |
<!-- END GENERATED: dns-provider-table -->

**Enabling wildcard certificates:**

Expand Down Expand Up @@ -1134,7 +1136,7 @@ rate limits (50 certificates per registered domain per week).
| Flag | Environment Variable | Default | Description |
|------|---------------------|---------|-------------|
| `--acme-email` | `ACME_EMAIL` | (required) | Contact email for Let's Encrypt |
| `--acme-dns-provider` | `ACME_DNS_PROVIDER` | `auto` | DNS provider (cloudflare, route53, digitalocean, gcloud, namecheap, godaddy, hetzner, vultr, auto). Repeatable; `zone=provider` entries pin a zone to its DNS host |
| `--acme-dns-provider` | `ACME_DNS_PROVIDER` | `auto` | DNS provider for DNS-01 challenges — any name from the supported-providers table above, or `auto`. Repeatable; `zone=provider` entries pin a zone to its DNS host |
Comment thread
mhenrixon marked this conversation as resolved.
| `--acme-directory` | `ACME_DIRECTORY` | Let's Encrypt production | ACME directory URL |
| `--acme-prefer-wildcard` | `ACME_PREFER_WILDCARD` | `true` | Prefer wildcard certificates when DNS provider available |
| `--acme-http-fallback` | `ACME_HTTP_FALLBACK` | `true` | Fall back to HTTP-01 challenge if DNS-01 fails |
Expand Down
3 changes: 2 additions & 1 deletion internal/cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

"github.com/basecamp/kamal-proxy/internal/server"
"github.com/basecamp/kamal-proxy/internal/server/acme"
"github.com/basecamp/kamal-proxy/internal/server/acme/providers"
)

type runCommand struct {
Expand Down Expand Up @@ -66,7 +67,7 @@ func newRunCommand() *runCommand {
runCommand.cmd.Flags().StringVar(&globalConfig.MinTLS, "min-tls", getEnvString("MIN_TLS", server.DefaultMinTLSVersion), "Lowest TLS version the HTTPS listener will negotiate: 1.2 or 1.3 (TLS 1.0 and 1.1 cannot be enabled; HTTP/3 is always 1.3)")
runCommand.cmd.Flags().StringVar(&globalConfig.ACMEEmail, "acme-email", getEnvString("ACME_EMAIL", ""), "Email address for ACME account registration (required for automatic TLS)")
runCommand.cmd.Flags().StringVar(&globalConfig.ACMEDirectory, "acme-directory", getEnvString("ACME_DIRECTORY", server.LetsEncryptProduction), "ACME directory URL")
runCommand.cmd.Flags().StringSliceVar(&runCommand.acmeDNSProviders, "acme-dns-provider", strings.Split(getEnvString("ACME_DNS_PROVIDER", "auto"), ","), "DNS provider for DNS-01 challenges (cloudflare, route53, digitalocean, gcloud, namecheap, godaddy, hetzner, vultr, auto). Repeatable: zone=provider entries pin a zone to the DNS host that serves it, and one bare entry is the default for unmatched zones")
runCommand.cmd.Flags().StringSliceVar(&runCommand.acmeDNSProviders, "acme-dns-provider", strings.Split(getEnvString("ACME_DNS_PROVIDER", "auto"), ","), "DNS provider for DNS-01 challenges (one of: "+providers.ProviderListForHelp()+"). Repeatable: zone=provider entries pin a zone to the DNS host that serves it, and one bare entry is the default for unmatched zones")
runCommand.cmd.Flags().BoolVar(&globalConfig.ACMEPreferWildcard, "acme-prefer-wildcard", getEnvBool("ACME_PREFER_WILDCARD", true), "Prefer wildcard certificates when DNS provider available")
runCommand.cmd.Flags().BoolVar(&globalConfig.ACMEHTTPFallback, "acme-http-fallback", getEnvBool("ACME_HTTP_FALLBACK", true), "Fall back to HTTP-01 challenge if DNS-01 fails")

Expand Down
23 changes: 23 additions & 0 deletions internal/cmd/run_providers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package cmd

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/basecamp/kamal-proxy/internal/server/acme"
"github.com/basecamp/kamal-proxy/internal/server/acme/providers"
)

// The --acme-dns-provider help enumerates the registry, not a hand-written
// list: a provider added to the registry appears here with no other change.
func TestRunCommand_DNSProviderHelpMatchesRegistry(t *testing.T) {
flag := newRunCommand().cmd.Flags().Lookup("acme-dns-provider")
require.NotNil(t, flag)

for _, name := range providers.Names() {
assert.Contains(t, flag.Usage, string(name))
}
assert.Contains(t, flag.Usage, string(acme.ProviderAuto))
}
15 changes: 0 additions & 15 deletions internal/server/acme/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,21 +34,6 @@ const DefaultProductionDirectory = "https://acme-v02.api.letsencrypt.org/directo
// DefaultStagingDirectory is the Let's Encrypt staging ACME directory
const DefaultStagingDirectory = "https://acme-staging-v02.api.letsencrypt.org/directory"

// GetSupportedProviders returns a list of supported DNS providers
func GetSupportedProviders() []ProviderName {
return []ProviderName{
ProviderCloudflare,
ProviderRoute53,
ProviderDigitalOcean,
ProviderGoogleCloud,
ProviderNamecheap,
ProviderGoDaddy,
ProviderHetzner,
ProviderVultr,
ProviderAuto,
}
}

// ParseProviderName parses a string into a ProviderName
func ParseProviderName(s string) (ProviderName, error) {
switch strings.ToLower(s) {
Expand Down
17 changes: 0 additions & 17 deletions internal/server/acme/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,23 +75,6 @@ func TestParseProviderName(t *testing.T) {
}
}

func TestGetSupportedProviders(t *testing.T) {
providers := GetSupportedProviders()

// Should have all expected providers
assert.Contains(t, providers, ProviderCloudflare)
assert.Contains(t, providers, ProviderRoute53)
assert.Contains(t, providers, ProviderDigitalOcean)
assert.Contains(t, providers, ProviderGoogleCloud)
assert.Contains(t, providers, ProviderNamecheap)
assert.Contains(t, providers, ProviderGoDaddy)
assert.Contains(t, providers, ProviderHetzner)
assert.Contains(t, providers, ProviderVultr)
assert.Contains(t, providers, ProviderAuto)

assert.Len(t, providers, 9)
}

func TestCheckEnvVars(t *testing.T) {
tests := []struct {
name string
Expand Down
112 changes: 112 additions & 0 deletions internal/server/acme/providers/docs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package providers
Comment thread
mhenrixon marked this conversation as resolved.

import (
"fmt"
"slices"
"strings"

"github.com/basecamp/kamal-proxy/internal/server/acme"
)

// Documentation derived from the registry: the --acme-dns-provider help list,
// the README's supported-provider table, and the marker-based replacement
// shared by the generator tool and the drift test. The registry is the single
// source of truth; nothing here is hand-maintained.

//go:generate go run ./gen

const (
providerTableBeginMarker = "<!-- BEGIN GENERATED: dns-provider-table (go generate ./internal/server/acme/providers) -->"
providerTableEndMarker = "<!-- END GENERATED: dns-provider-table -->"
)

// Names returns every registered provider name, sorted.
func Names() []acme.ProviderName {
names := make([]acme.ProviderName, 0, len(registry))
for name := range registry {
names = append(names, name)
}
slices.Sort(names)
return names
}

// ProviderListForHelp renders the provider names for the --acme-dns-provider
// flag help: the registry, sorted, plus the auto pseudo-provider.
func ProviderListForHelp() string {
parts := make([]string, 0, len(registry)+1)
for _, name := range Names() {
parts = append(parts, string(name))
}
parts = append(parts, string(acme.ProviderAuto))
return strings.Join(parts, ", ")
}

// ProviderTableMarkdown renders the supported-provider table: one row per
// registry entry, leading with the name the --acme-dns-provider flag accepts
// (a display name like "AWS Route53" is not a flag value), then the display
// name linked to its lego documentation, the credential rule as the same
// OR-of-ANDs the boot check enforces, and the optional variables the entry
// names.
func ProviderTableMarkdown() string {
var b strings.Builder
b.WriteString("| Name | Provider | Credentials | Optional |\n")
b.WriteString("|------|----------|-------------|----------|\n")

for _, name := range Names() {
provider := registry[name]
fmt.Fprintf(&b, "| `%s` | [%s](%s) | %s | %s |\n",
name, provider.DisplayName, provider.Docs,
markdownCredentialSets(provider.credentialSets()),
markdownVars(provider.Optional))
}

return b.String()
}

// ReplaceProviderTable substitutes the generated provider table between the
// README's markers, leaving everything else untouched. It errors when the
// markers are missing — silently appending a table nobody asked for is how a
// generator corrupts a document.
func ReplaceProviderTable(document string) (string, error) {
begin := strings.Index(document, providerTableBeginMarker)
end := strings.Index(document, providerTableEndMarker)
if begin == -1 || end == -1 || end < begin {
return "", fmt.Errorf("provider table markers not found (need %q ... %q)",
providerTableBeginMarker, providerTableEndMarker)
}

return document[:begin+len(providerTableBeginMarker)] +
"\n" + ProviderTableMarkdown() +
document[end:], nil
}

// markdownCredentialSets renders an OR-of-ANDs credential rule with each
// variable in backticks, e.g. "`CF_API_TOKEN` or (`CF_API_KEY` + `CF_API_EMAIL`)".
func markdownCredentialSets(sets [][]string) string {
parts := make([]string, 0, len(sets))
for _, set := range sets {
joined := make([]string, 0, len(set))
for _, envVar := range set {
joined = append(joined, "`"+envVar+"`")
}
if len(set) > 1 {
parts = append(parts, "("+strings.Join(joined, " + ")+")")
} else {
parts = append(parts, strings.Join(joined, " + "))
}
}
return strings.Join(parts, " or ")
}

// markdownVars renders a plain list of env vars in backticks, or a dash for
// none.
func markdownVars(vars []string) string {
if len(vars) == 0 {
return "—"
}
quoted := make([]string, 0, len(vars))
for _, envVar := range vars {
quoted = append(quoted, "`"+envVar+"`")
}
return strings.Join(quoted, ", ")
}
103 changes: 103 additions & 0 deletions internal/server/acme/providers/docs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package providers

import (
"os"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/basecamp/kamal-proxy/internal/server/acme"
)

func TestNames_CoversRegistryExactlySorted(t *testing.T) {
names := Names()

require.Len(t, names, len(registry))
for _, name := range names {
assert.Contains(t, registry, name)
}
assert.IsIncreasing(t, names)
}

func TestProviderTableMarkdown_RendersEveryRegistryEntry(t *testing.T) {
table := ProviderTableMarkdown()

for name, provider := range registry {
assert.Contains(t, table, "| `"+string(name)+"` | ["+provider.DisplayName+"]("+provider.Docs+")",
"provider %s must appear with its flag value and docs link", name)
}

// The credential column renders the full OR-of-ANDs rule, not prose:
// Cloudflare's third alternative was exactly what the hand-written table
// had already lost.
assert.Contains(t, table, "`CF_API_TOKEN` or `CF_DNS_API_TOKEN` or (`CF_API_KEY` + `CF_API_EMAIL`)")
assert.Contains(t, table, "`AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY`")

// Optional vars come from the registry entry, not prose.
assert.Contains(t, table, "`NAMECHEAP_SANDBOX`")

// Deterministic: two renders are identical.
assert.Equal(t, table, ProviderTableMarkdown())
}

func TestReplaceProviderTable_RewritesOnlyTheMarkedBlock(t *testing.T) {
doc := "before\n" + providerTableBeginMarker + "\nstale table\n" + providerTableEndMarker + "\nafter\n"

replaced, err := ReplaceProviderTable(doc)
require.NoError(t, err)

assert.True(t, strings.HasPrefix(replaced, "before\n"))
assert.True(t, strings.HasSuffix(replaced, "\nafter\n"))
assert.NotContains(t, replaced, "stale table")
assert.Contains(t, replaced, ProviderTableMarkdown())

// Idempotent: replacing again changes nothing.
again, err := ReplaceProviderTable(replaced)
require.NoError(t, err)
assert.Equal(t, replaced, again)
}

func TestReplaceProviderTable_FailsWithoutMarkers(t *testing.T) {
_, err := ReplaceProviderTable("a document with no markers")
require.Error(t, err)
}

// The committed README table must match what the registry renders; a provider
// added to the registry without regenerating fails here, named.
func TestREADMEProviderTable_MatchesRegistry(t *testing.T) {
data, err := os.ReadFile("../../../../README.md")
require.NoError(t, err)

regenerated, err := ReplaceProviderTable(string(data))
require.NoError(t, err, "README.md must carry the provider table markers")

if string(data) == regenerated {
return
}

// Name the rows the registry renders that the committed table lacks —
// a brand-new provider AND a changed row (credentials, optional vars)
// both surface as an expected line the document does not contain.
stale := []string{}
for line := range strings.Lines(ProviderTableMarkdown()) {

@cubic-dev-ai cubic-dev-ai Bot Aug 14, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: When a provider is removed from the registry but its row remains in README, the equality gate fails while stale stays empty (every rendered line is still present in data), so the error prints "stale or missing rows:" with no rows and no clue which row is obsolete. The substring Contains check can also miss a genuinely removed row when a rendered line appears elsewhere in the document. Compare the committed table region (between the begin/end markers) against the rendered lines to also surface rows that exist in README but are no longer generated.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At internal/server/acme/providers/docs_test.go, line 84:

<comment>When a provider is removed from the registry but its row remains in README, the equality gate fails while `stale` stays empty (every rendered line is still present in `data`), so the error prints "stale or missing rows:" with no rows and no clue which row is obsolete. The substring `Contains` check can also miss a genuinely removed row when a rendered line appears elsewhere in the document. Compare the committed table region (between the begin/end markers) against the rendered lines to also surface rows that exist in README but are no longer generated.</comment>

<file context>
@@ -77,13 +77,18 @@ func TestREADMEProviderTable_MatchesRegistry(t *testing.T) {
+	// a brand-new provider AND a changed row (credentials, optional vars)
+	// both surface as an expected line the document does not contain.
+	stale := []string{}
+	for line := range strings.Lines(ProviderTableMarkdown()) {
+		line = strings.TrimSuffix(line, "\n")
+		if line != "" && !strings.Contains(string(data), line) {
</file context>
Fix with cubic

line = strings.TrimSuffix(line, "\n")
if line != "" && !strings.Contains(string(data), line) {
stale = append(stale, line)
}
}
t.Fatalf("README.md provider table is out of date (run `go generate ./internal/server/acme/providers`); stale or missing rows:\n%s",
strings.Join(stale, "\n"))
}

// The flag help must enumerate the registry, plus the auto pseudo-provider,
// with no hand-maintained copy anywhere.
func TestProviderListForHelp_MatchesRegistry(t *testing.T) {
help := ProviderListForHelp()

for name := range registry {
assert.Contains(t, help, string(name))
}
assert.True(t, strings.HasSuffix(help, ", "+string(acme.ProviderAuto)))
}
38 changes: 38 additions & 0 deletions internal/server/acme/providers/gen/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Command gen rewrites the README's supported-DNS-provider table from the
// provider registry. Run via `go generate ./internal/server/acme/providers`;
// TestREADMEProviderTable_MatchesRegistry fails the build when the committed
// table does not match what this would write.
package main

import (
"fmt"
"os"

"github.com/basecamp/kamal-proxy/internal/server/acme/providers"
)

func main() {
const readme = "../../../../README.md"

data, err := os.ReadFile(readme)
if err != nil {
fmt.Fprintf(os.Stderr, "gen: %v\n", err)
os.Exit(1)
}

replaced, err := providers.ReplaceProviderTable(string(data))
if err != nil {
fmt.Fprintf(os.Stderr, "gen: %v\n", err)
os.Exit(1)
}

if replaced == string(data) {
return
}

if err := os.WriteFile(readme, []byte(replaced), 0644); err != nil {
fmt.Fprintf(os.Stderr, "gen: %v\n", err)
os.Exit(1)
}
fmt.Println("gen: README.md provider table regenerated")
}
Loading
Loading