diff --git a/README.md b/README.md index 7086d47..463b61b 100644 --- a/README.md +++ b/README.md @@ -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` | + +| 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` | — | + **Enabling wildcard certificates:** @@ -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 | | `--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 | diff --git a/internal/cmd/run.go b/internal/cmd/run.go index be63ac5..2b64b9f 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -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 { @@ -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") diff --git a/internal/cmd/run_providers_test.go b/internal/cmd/run_providers_test.go new file mode 100644 index 0000000..e04c416 --- /dev/null +++ b/internal/cmd/run_providers_test.go @@ -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)) +} diff --git a/internal/server/acme/provider.go b/internal/server/acme/provider.go index f369f63..0d7431e 100644 --- a/internal/server/acme/provider.go +++ b/internal/server/acme/provider.go @@ -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) { diff --git a/internal/server/acme/provider_test.go b/internal/server/acme/provider_test.go index 260dfce..e64c208 100644 --- a/internal/server/acme/provider_test.go +++ b/internal/server/acme/provider_test.go @@ -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 diff --git a/internal/server/acme/providers/docs.go b/internal/server/acme/providers/docs.go new file mode 100644 index 0000000..0a61e2d --- /dev/null +++ b/internal/server/acme/providers/docs.go @@ -0,0 +1,112 @@ +package providers + +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 = "" + providerTableEndMarker = "" +) + +// 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, ", ") +} diff --git a/internal/server/acme/providers/docs_test.go b/internal/server/acme/providers/docs_test.go new file mode 100644 index 0000000..da52090 --- /dev/null +++ b/internal/server/acme/providers/docs_test.go @@ -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()) { + 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))) +} diff --git a/internal/server/acme/providers/gen/main.go b/internal/server/acme/providers/gen/main.go new file mode 100644 index 0000000..cf2ae1e --- /dev/null +++ b/internal/server/acme/providers/gen/main.go @@ -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") +} diff --git a/internal/server/acme/providers/registry_test.go b/internal/server/acme/providers/registry_test.go index 8cee167..71bceb6 100644 --- a/internal/server/acme/providers/registry_test.go +++ b/internal/server/acme/providers/registry_test.go @@ -1,7 +1,6 @@ package providers import ( - "slices" "testing" "github.com/basecamp/kamal-proxy/internal/server/acme" @@ -113,22 +112,3 @@ func TestNewProvider_Route53SkipsCredentialCheck(t *testing.T) { require.True(t, ok) assert.True(t, provider.NoBootCheck) } - -// GetSupportedProviders and the registry must agree, "auto" aside. -func TestRegistry_MatchesSupportedProviders(t *testing.T) { - supported := acme.GetSupportedProviders() - - for name := range registry { - assert.Contains(t, supported, name) - } - - for _, name := range supported { - if name == acme.ProviderAuto { - continue - } - _, ok := registry[name] - assert.True(t, ok, "supported provider %s missing from registry", name) - } - - assert.True(t, slices.Contains(supported, acme.ProviderAuto)) -}