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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ The `dns_provider_env` in this case is `DO_AUTH_TOKEN`
- `directory_url` (optional): ACME directory URL (defaults to Let's Encrypt production)
- `key_type` (optional): Key type for the account key (`EC256`, `EC384`, `RSA2048`, `RSA4096`, `RSA8192`)
- `dns_provider_env` (optional): Key-value pairs of environment variables to set for DNS provider authentication
- `dns_resolvers` (optional): `host:port` resolvers to use for DNS-01 SOA/propagation lookups (e.g. `1.1.1.1:53,8.8.8.8:53`). Useful in split-horizon DNS setups
- `skip_authoritative_ns_check` (optional): Skip the authoritative-NS propagation gate before requesting validation

### Reading Account Information

Expand All @@ -136,13 +138,32 @@ vault read letsencrypt/certs/dns-01/myaccount/cloudflare/example.com
- `provider`: The DNS provider name (e.g., `cloudflare`, `route53`, `gcloud`, etc.)
- `fqdn`: The fully qualified domain name for the certificate

**Parameters:**
- `alt_names` (optional): Comma-separated list of additional Subject
Alternative Names to request alongside the FQDN. The FQDN is always
the certificate's Common Name and the first SAN; `alt_names` entries
are added as further SANs. Persisted with the cert — renewal reads
(no parameters) reuse the stored list. Supplying a different list
forces re-issuance.

**Response:**
The response includes:
- `certificate`: PEM-encoded certificate chain
- `private_key`: PEM-encoded private key

The secret has a TTL set to expire 30 days before the certificate expires, ensuring automatic renewal.

#### Multi-SAN example

```bash
vault read letsencrypt/certs/dns-01/myaccount/cloudflare/node1.example.com \
alt_names=service.example.com,node1-alt.example.com
```

Issues a cert with CN=`node1.example.com` and SANs=`[node1.example.com,
service.example.com, node1-alt.example.com]`. Subsequent reads (no
`alt_names`) renew against the same name set.

### Supported DNS Providers

The plugin supports all DNS providers available in the LEGO library, including:
Expand Down
12 changes: 7 additions & 5 deletions backend/account.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,13 @@ type accountKey struct {
}

type account struct {
Email string
Registration *registration.Resource
Key accountKey
DirectoryURL string
DNSProviderEnv map[string]string
Email string
Registration *registration.Resource
Key accountKey
DirectoryURL string
DNSProviderEnv map[string]string
DnsResolvers []string
SkipAuthoritativeNSCheck bool
}

// LEGO registration.User interface
Expand Down
4 changes: 4 additions & 0 deletions backend/cert.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
type cert struct {
CertificateChain []*x509.Certificate
Key accountKey
AltNames []string
}

func getCert(ctx context.Context, storage logical.Storage, path string) (*cert, error) {
Expand Down Expand Up @@ -68,12 +69,14 @@ func (c *cert) write(ctx context.Context, storage logical.Storage, path string)
type certJSON struct {
CertificateChain [][]byte
Key accountKey
AltNames []string `json:",omitempty"`
}

func (c *cert) MarshalJSON() ([]byte, error) {
cj := &certJSON{
CertificateChain: make([][]byte, len(c.CertificateChain)),
Key: c.Key,
AltNames: c.AltNames,
}

for ii := range c.CertificateChain {
Expand All @@ -91,6 +94,7 @@ func (c *cert) UnmarshalJSON(data []byte) error {

c.CertificateChain = make([]*x509.Certificate, len(cj.CertificateChain))
c.Key = cj.Key
c.AltNames = cj.AltNames

for ii := range cj.CertificateChain {
cert, err := x509.ParseCertificate(cj.CertificateChain[ii])
Expand Down
25 changes: 25 additions & 0 deletions backend/path_account.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@ func pathAccounts(b *backend) []*framework.Path {
Type: framework.TypeKVPairs,
Description: "Environment variables to set when performing dns-01 challenges",
},
"dns_resolvers": {
Type: framework.TypeCommaStringSlice,
Description: "Recursive DNS resolvers (host:port) to use when looking up SOA and TXT records during the DNS-01 challenge. Useful in split-horizon DNS setups where the host's resolver is not authoritative for the public zone.",
},
"skip_authoritative_ns_check": {
Type: framework.TypeBool,
Default: false,
Description: "If true, skip the authoritative nameserver propagation check before asking the ACME server to validate.",
},
},
Operations: map[logical.Operation]framework.OperationHandler{
logical.ReadOperation: &framework.PathOperation{
Expand Down Expand Up @@ -98,6 +107,14 @@ func (b *backend) accountsRead(ctx context.Context, req *logical.Request, data *
resp["dns_provider_env"] = a.DNSProviderEnv
}

if a.DnsResolvers == nil {
resp["dns_resolvers"] = []string{}
} else {
resp["dns_resolvers"] = a.DnsResolvers
}

resp["skip_authoritative_ns_check"] = a.SkipAuthoritativeNSCheck

return &logical.Response{
Data: resp,
}, nil
Expand Down Expand Up @@ -154,6 +171,14 @@ func (b *backend) accountsWrite(ctx context.Context, req *logical.Request, data
}
}

if resolversRaw, ok := data.GetOk("dns_resolvers"); ok {
act.DnsResolvers = resolversRaw.([]string)
}

if skipRaw, ok := data.GetOk("skip_authoritative_ns_check"); ok {
act.SkipAuthoritativeNSCheck = skipRaw.(bool)
}

client, err := act.NewClient(b.tlsConfig)
if err != nil {
return nil, err
Expand Down
72 changes: 72 additions & 0 deletions backend/path_account_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,78 @@ func TestPathAccounts_Write_WithDNSEnv_Merge(t *testing.T) {
assert.Equal(t, dnsEnv, act.DNSProviderEnv)
}

func TestPathAccounts_Write_WithDNSResolvers(t *testing.T) {
b := createTestBackend(t)

as := b.startACMEServer(t)
defer as.Close()

path := "accounts/test-account"
resolvers := []string{"1.1.1.1:53", "8.8.8.8:53"}

req := &logical.Request{
Path: path,
Operation: logical.UpdateOperation,
Data: map[string]interface{}{
"email": "test@example.com",
"directory_url": as.DirectoryURL,
"tos_agreed": true,
"dns_resolvers": resolvers,
"skip_authoritative_ns_check": true,
},
}

resp, err := b.HandleRequest(t, req)
require.NoError(t, err)
require.NotNil(t, resp)
require.NoError(t, resp.Error())
require.NotNil(t, resp.Data)

assert.Equal(t, resolvers, resp.Data["dns_resolvers"])
assert.Equal(t, true, resp.Data["skip_authoritative_ns_check"])

act, err := getAccount(t.Context(), b.Storage, path)
require.NoError(t, err)
require.NotNil(t, act)
assert.Equal(t, resolvers, act.DnsResolvers)
assert.True(t, act.SkipAuthoritativeNSCheck)
}

func TestPathAccounts_Read_DNSResolversDefaults(t *testing.T) {
b := createTestBackend(t)

const path = "accounts/test-account"
act := &account{
Email: "test@example.com",
DirectoryURL: "https://acme.example.com/directory",
Registration: &registration.Resource{
URI: "https://registration.example.com/reg/123",
Body: acme.Account{
TermsOfServiceAgreed: true,
},
},
}
err := act.Key.Generate(KeyTypeEC256)
require.NoError(t, err)

err = act.write(t.Context(), b.Storage, path)
require.NoError(t, err)

req := &logical.Request{
Path: path,
Operation: logical.ReadOperation,
}

resp, err := b.HandleRequest(t, req)
require.NoError(t, err)
require.NotNil(t, resp)
require.NoError(t, resp.Error())
require.NotNil(t, resp.Data)

assert.Equal(t, []string{}, resp.Data["dns_resolvers"])
assert.Equal(t, false, resp.Data["skip_authoritative_ns_check"])
}

func TestPathAccounts_Delete_WithoutRegistration(t *testing.T) {
b := createTestBackend(t)

Expand Down
81 changes: 77 additions & 4 deletions backend/path_cert.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"encoding/pem"
"errors"
"fmt"
"sort"
"time"

"github.com/go-acme/lego/v4/certcrypto"
Expand Down Expand Up @@ -48,7 +49,17 @@ func pathCerts(b *backend) []*framework.Path {
"fqdn": {
Type: framework.TypeString,
Required: true,
Description: "FQDN to manage",
Description: "FQDN to manage (becomes the CN and is " +
"always the first SAN)",
},
"alt_names": {
Type: framework.TypeCommaStringSlice,
Required: false,
Description: "Additional subject alternative names " +
"(SANs) to request alongside the FQDN. Persisted " +
"with the cert; renewal reuses the stored list " +
"when this field is omitted. Supplying a " +
"different list forces re-issuance.",
},
},
Operations: map[logical.Operation]framework.OperationHandler{
Expand All @@ -70,6 +81,11 @@ func (b *backend) certsRead(ctx context.Context, req *logical.Request, data *fra
provider := data.Get("provider").(string)
fqdn := data.Get("fqdn").(string)

// alt_names is only honored when supplied. On renewal reads (which
// pass no parameters), the stored list on the existing cert is what
// drives the next ObtainRequest.
altNames, altNamesProvided := altNamesFromData(data)

// check existing certificate
c, err := getCert(ctx, req.Storage, req.Path)
if err != nil {
Expand All @@ -78,12 +94,24 @@ func (b *backend) certsRead(ctx context.Context, req *logical.Request, data *fra

if c != nil && len(c.CertificateChain) > 0 && c.Key.PrivateKey != nil {

// A supplied alt_names list that differs from what's stored
// forces re-issuance even if the existing cert is otherwise
// still inside its renewal window.
altNamesChanged := altNamesProvided && !stringSlicesEqual(altNames, c.AltNames)

timeUntilRenwal := time.Until(c.RenewalDeadline())
if timeUntilRenwal > 0 {
if timeUntilRenwal > 0 && !altNamesChanged {
return b.certResponse(ctx, c, req, account, provider)
}
}

// Renewal path: caller didn't supply alt_names, so reuse whatever
// was stored with the existing cert (empty slice for legacy certs
// issued before this field existed).
if !altNamesProvided && c != nil {
altNames = c.AltNames
}

actPath := fmt.Sprintf("accounts/%s", account)
act, err := getAccount(ctx, req.Storage, actPath)
if err != nil {
Expand Down Expand Up @@ -116,7 +144,12 @@ func (b *backend) certsRead(ctx context.Context, req *logical.Request, data *fra
b.Logger().Debug("Setting DNS nameservers to", "dns-resolvers", b.dnsResolvers)
}

if b.skipAuthoritativeNSCheck || provider == "nil" {
if len(act.DnsResolvers) > 0 {
opts = append(opts, dns01.AddRecursiveNameservers(dns01.ParseNameservers(act.DnsResolvers)))
b.Logger().Debug("Setting DNS nameservers from account to", "dns-resolvers", act.DnsResolvers)
}

if b.skipAuthoritativeNSCheck || act.SkipAuthoritativeNSCheck || provider == "nil" {
opts = append(opts, dns01.DisableAuthoritativeNssPropagationRequirement())
b.Logger().Debug("Skipping authoritative NS checks")
}
Expand All @@ -133,8 +166,14 @@ func (b *backend) certsRead(ctx context.Context, req *logical.Request, data *fra
return nil, err
}

// lego treats the first Domain as the CN and includes every entry
// as a SAN. The ACME server is permitted to add the CN as a SAN
// implicitly, so passing fqdn once is sufficient even when
// altNames is empty.
domains := append([]string{fqdn}, altNames...)

certReq := certificate.ObtainRequest{
Domains: []string{fqdn},
Domains: domains,
Bundle: true,
}

Expand Down Expand Up @@ -167,6 +206,7 @@ func (b *backend) certsRead(ctx context.Context, req *logical.Request, data *fra
c = &cert{
CertificateChain: certChain,
Key: accountKey{privateKey},
AltNames: altNames,
}

err = c.write(ctx, req.Storage, req.Path)
Expand Down Expand Up @@ -216,3 +256,36 @@ func (b *backend) certResponse(ctx context.Context, c *cert, req *logical.Reques

return response, nil
}

// altNamesFromData extracts the alt_names field. The second return is
// true when the caller actually supplied the field — needed to
// distinguish "deliberately empty" from "omitted on a renewal read"
// without losing the stored list.
func altNamesFromData(data *framework.FieldData) ([]string, bool) {
raw, ok := data.GetOk("alt_names")
if !ok {
return nil, false
}
names, _ := raw.([]string)
return names, true
}

// stringSlicesEqual compares two slices ignoring order. Used to detect
// alt_names changes — operators may rebuild the list from a set or
// reorder freely in inventory, and an order-only change shouldn't
// force a re-issuance.
func stringSlicesEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
aCopy := append([]string(nil), a...)
bCopy := append([]string(nil), b...)
sort.Strings(aCopy)
sort.Strings(bCopy)
for i := range aCopy {
if aCopy[i] != bCopy[i] {
return false
}
}
return true
}
Loading