diff --git a/man/coredns-etcd.7 b/man/coredns-etcd.7 index 371f81f2b..f3484adde 100644 --- a/man/coredns-etcd.7 +++ b/man/coredns-etcd.7 @@ -1,5 +1,5 @@ .\" Generated by Mmark Markdown Processer - mmark.miek.nl -.TH "COREDNS-ETCD" 7 "March 2021" "CoreDNS" "CoreDNS Plugins" +.TH "COREDNS-ETCD" 7 "August 2025" "CoreDNS" "CoreDNS Plugins" .SH "NAME" .PP @@ -85,6 +85,10 @@ file - if the server certificate is not signed by a system-installed CA and clie is needed. .RE +.IP \(bu 4 +\fB\fCmin-lease-ttl\fR the minimum TTL for DNS records based on etcd lease duration. Accepts flexible time formats like '30', '30s', '5m', '1h', '2h30m'. Default: 30 seconds. +.IP \(bu 4 +\fB\fCmax-lease-ttl\fR the maximum TTL for DNS records based on etcd lease duration. Accepts flexible time formats like '30', '30s', '5m', '1h', '2h30m'. Default: 24 hours. .SH "SPECIAL BEHAVIOUR" @@ -93,7 +97,7 @@ The \fIetcd\fP plugin leverages directory structure to look for related entries. an entry \fB\fC/skydns/test/skydns/mx\fR would have entries like \fB\fC/skydns/test/skydns/mx/a\fR, \fB\fC/skydns/test/skydns/mx/b\fR and so on. Similarly a directory \fB\fC/skydns/test/skydns/mx1\fR will have all \fB\fCmx1\fR entries. Note this plugin will search through the entire (sub)tree for records. In case of the -first example, a query for \fB\fCmx.skydns.text\fR will return both the contents of the \fB\fCa\fR and \fB\fCb\fR records. +first example, a query for \fB\fCmx.skydns.test\fR will return both the contents of the \fB\fCa\fR and \fB\fCb\fR records. If the directory extends deeper those records are returned as well. .PP @@ -120,6 +124,8 @@ skydns.local { etcd { path /skydns endpoint http://localhost:2379 + min\-lease\-ttl 60 # minimum 1 minute for lease\-based records + max\-lease\-ttl 1h # maximum 1 hour for lease\-based records } prometheus cache @@ -349,6 +355,7 @@ If you would like to use \fB\fCTXT\fR records, you can set the following: .nf % etcdctl put /skydns/local/skydns/x6 '{"ttl":60,"text":"this is a random text message."}' +% etcdctl put /skydns/local/skydns/x7 '{"ttl":60,"text":"this is a another random text message."}' .fi .RE @@ -362,6 +369,7 @@ If you query the zone name for \fB\fCTXT\fR now, you will get the following resp .nf % dig +short skydns.local TXT @localhost "this is a random text message." +"this is a another random text message." .fi .RE diff --git a/plugin/etcd/README.md b/plugin/etcd/README.md index 0c7f1ea33..f01a66931 100644 --- a/plugin/etcd/README.md +++ b/plugin/etcd/README.md @@ -55,6 +55,8 @@ etcd [ZONES...] { * three arguments - path to cert PEM file, path to client private key PEM file, path to CA PEM file - if the server certificate is not signed by a system-installed CA and client certificate is needed. +* `min-lease-ttl` the minimum TTL for DNS records based on etcd lease duration. Accepts flexible time formats like '30', '30s', '5m', '1h', '2h30m'. Default: 30 seconds. +* `max-lease-ttl` the maximum TTL for DNS records based on etcd lease duration. Accepts flexible time formats like '30', '30s', '5m', '1h', '2h30m'. Default: 24 hours. ## Special Behaviour @@ -83,6 +85,8 @@ skydns.local { etcd { path /skydns endpoint http://localhost:2379 + min-lease-ttl 60 # minimum 1 minute for lease-based records + max-lease-ttl 1h # maximum 1 hour for lease-based records } prometheus cache diff --git a/plugin/etcd/etcd.go b/plugin/etcd/etcd.go index a78673027..b311def1b 100644 --- a/plugin/etcd/etcd.go +++ b/plugin/etcd/etcd.go @@ -21,21 +21,25 @@ import ( ) const ( - priority = 10 // default priority when nothing is set - ttl = 300 // default ttl when nothing is set - etcdTimeout = 5 * time.Second + defaultPriority = 10 // default priority when nothing is set + defaultTTL = 300 // default ttl when nothing is set + defaultLeaseMinTTL = 30 // default minimum TTL for lease-based records + defaultLeaseMaxTTL = 86400 // default maximum TTL for lease-based records + etcdTimeout = 5 * time.Second ) var errKeyNotFound = errors.New("key not found") // Etcd is a plugin talks to an etcd cluster. type Etcd struct { - Next plugin.Handler - Fall fall.F - Zones []string - PathPrefix string - Upstream *upstream.Upstream - Client *etcdcv3.Client + Next plugin.Handler + Fall fall.F + Zones []string + PathPrefix string + Upstream *upstream.Upstream + Client *etcdcv3.Client + MinLeaseTTL uint32 // minimum TTL for lease-based records + MaxLeaseTTL uint32 // maximum TTL for lease-based records endpoints []string // Stored here as well, to aid in testing. } @@ -146,7 +150,7 @@ Nodes: serv.TTL = e.TTL(n, serv) if serv.Priority == 0 { - serv.Priority = priority + serv.Priority = defaultPriority } if shouldInclude(serv, qType) { @@ -159,10 +163,39 @@ Nodes: // TTL returns the smaller of the etcd TTL and the service's // TTL. If neither of these are set (have a zero value), a default is used. func (e *Etcd) TTL(kv *mvccpb.KeyValue, serv *msg.Service) uint32 { - etcdTTL := uint32(kv.Lease) + var etcdTTL uint32 + + // Get actual lease TTL from etcd if lease exists and client is available + if kv.Lease != 0 && e.Client != nil { + if resp, err := e.Client.TimeToLive(context.Background(), etcdcv3.LeaseID(kv.Lease)); err == nil && resp.TTL > 0 { + leaseTTL := resp.TTL + + // Get bounds with defaults + minTTL := e.MinLeaseTTL + if minTTL == 0 { + minTTL = defaultLeaseMinTTL + } + maxTTL := e.MaxLeaseTTL + if maxTTL == 0 { + maxTTL = defaultLeaseMaxTTL + } + + // Clamp lease TTL to configured bounds + minTTL64 := int64(minTTL) + maxTTL64 := int64(maxTTL) + + if leaseTTL < minTTL64 { + leaseTTL = minTTL64 + } else if leaseTTL > maxTTL64 { + leaseTTL = maxTTL64 + } + + etcdTTL = uint32(leaseTTL) + } + } if etcdTTL == 0 && serv.TTL == 0 { - return ttl + return defaultTTL } if etcdTTL == 0 { return serv.TTL diff --git a/plugin/etcd/setup.go b/plugin/etcd/setup.go index 68a1b7f42..2ddbf4597 100644 --- a/plugin/etcd/setup.go +++ b/plugin/etcd/setup.go @@ -2,7 +2,11 @@ package etcd import ( "crypto/tls" + "errors" "path/filepath" + "strconv" + "strings" + "time" "github.com/coredns/caddy" "github.com/coredns/coredns/core/dnsserver" @@ -33,7 +37,11 @@ func setup(c *caddy.Controller) error { func etcdParse(c *caddy.Controller) (*Etcd, error) { config := dnsserver.GetConfig(c) - etc := Etcd{PathPrefix: "skydns"} + etc := Etcd{ + PathPrefix: "skydns", + MinLeaseTTL: defaultLeaseMinTTL, + MaxLeaseTTL: defaultLeaseMaxTTL, + } var ( tlsConfig *tls.Config err error @@ -88,6 +96,24 @@ func etcdParse(c *caddy.Controller) (*Etcd, error) { return &Etcd{}, c.Errf("credentials requires 2 arguments, username and password") } username, password = args[0], args[1] + case "min-lease-ttl": + if !c.NextArg() { + return &Etcd{}, c.ArgErr() + } + minLeaseTTL, err := parseTTL(c.Val()) + if err != nil { + return &Etcd{}, c.Errf("invalid min-lease-ttl value: %v", err) + } + etc.MinLeaseTTL = minLeaseTTL + case "max-lease-ttl": + if !c.NextArg() { + return &Etcd{}, c.ArgErr() + } + maxLeaseTTL, err := parseTTL(c.Val()) + if err != nil { + return &Etcd{}, c.Errf("invalid max-lease-ttl value: %v", err) + } + etc.MaxLeaseTTL = maxLeaseTTL default: if c.Val() != "}" { return &Etcd{}, c.Errf("unknown property '%s'", c.Val()) @@ -124,3 +150,35 @@ func newEtcdClient(endpoints []string, cc *tls.Config, username, password string } const defaultEndpoint = "http://localhost:2379" + +// parseTTL parses a TTL value with flexible time units using Go's standard duration parsing. +// Supports formats like: "30", "30s", "5m", "1h", "90s", "2h30m", etc. +func parseTTL(s string) (uint32, error) { + s = strings.TrimSpace(s) + if s == "" { + return 0, nil + } + + // Handle plain numbers (assume seconds) + if _, err := strconv.ParseUint(s, 10, 64); err == nil { + // If it's just a number, append "s" for seconds + s += "s" + } + + // Use Go's standard time.ParseDuration for robust parsing + duration, err := time.ParseDuration(s) + if err != nil { + return 0, errors.New("invalid TTL format, use format like '30', '30s', '5m', '1h', or '2h30m'") + } + + // Convert to seconds and check bounds + seconds := duration.Seconds() + if seconds < 0 { + return 0, errors.New("TTL must be non-negative") + } + if seconds > 4294967295 { // uint32 max value + return 0, errors.New("TTL too large, maximum is 4294967295 seconds") + } + + return uint32(seconds), nil +} diff --git a/plugin/etcd/setup_test.go b/plugin/etcd/setup_test.go index 4922641bb..c88dd1044 100644 --- a/plugin/etcd/setup_test.go +++ b/plugin/etcd/setup_test.go @@ -66,6 +66,31 @@ func TestSetupEtcd(t *testing.T) { } `, true, "skydns", []string{"http://localhost:2379"}, "Wrong argument count", "", "", }, + // with custom min-lease-ttl + { + `etcd { + endpoint http://localhost:2379 + min-lease-ttl 60 + } + `, false, "skydns", []string{"http://localhost:2379"}, "", "", "", + }, + // with custom max-lease-ttl + { + `etcd { + endpoint http://localhost:2379 + max-lease-ttl 1h + } + `, false, "skydns", []string{"http://localhost:2379"}, "", "", "", + }, + // with both custom min-lease-ttl and max-lease-ttl + { + `etcd { + endpoint http://localhost:2379 + min-lease-ttl 120 + max-lease-ttl 7200 + } + `, false, "skydns", []string{"http://localhost:2379"}, "", "", "", + }, } for i, test := range tests { @@ -113,6 +138,84 @@ func TestSetupEtcd(t *testing.T) { t.Errorf("Etcd password not correctly set for input %s. Expected: '%+v', actual: '%+v'", test.input, test.password, etcd.Client.Password) } } + + // Check TTL configuration for specific test cases + if strings.Contains(test.input, "min-lease-ttl 60") { + if etcd.MinLeaseTTL != 60 { + t.Errorf("MinLeaseTTL not set correctly for input %s. Expected: 60, actual: %d", test.input, etcd.MinLeaseTTL) + } + } + if strings.Contains(test.input, "max-lease-ttl 1h") { + if etcd.MaxLeaseTTL != 3600 { + t.Errorf("MaxLeaseTTL not set correctly for input %s. Expected: 3600, actual: %d", test.input, etcd.MaxLeaseTTL) + } + } + if strings.Contains(test.input, "min-lease-ttl 120") && strings.Contains(test.input, "max-lease-ttl 7200") { + if etcd.MinLeaseTTL != 120 { + t.Errorf("MinLeaseTTL not set correctly for input %s. Expected: 120, actual: %d", test.input, etcd.MinLeaseTTL) + } + if etcd.MaxLeaseTTL != 7200 { + t.Errorf("MaxLeaseTTL not set correctly for input %s. Expected: 7200, actual: %d", test.input, etcd.MaxLeaseTTL) + } + } } } } + +func TestParseTTL(t *testing.T) { + tests := []struct { + input string + expected uint32 + hasError bool + desc string + }{ + // Plain numbers (assumed to be seconds) + {"30", 30, false, "plain number should be treated as seconds"}, + {"300", 300, false, "plain number should be treated as seconds"}, + + // Explicit seconds + {"30s", 30, false, "explicit seconds"}, + {"90s", 90, false, "explicit seconds"}, + + // Minutes + {"5m", 300, false, "5 minutes"}, + {"1m", 60, false, "1 minute"}, + + // Hours + {"1h", 3600, false, "1 hour"}, + {"2h", 7200, false, "2 hours"}, + + // Complex durations (Go's ParseDuration supports this) + {"2h30m", 9000, false, "2 hours 30 minutes"}, + {"1h30m45s", 5445, false, "1 hour 30 minutes 45 seconds"}, + + // Edge cases + {"0", 0, false, "zero should be allowed"}, + {"0s", 0, false, "zero seconds should be allowed"}, + {"", 0, false, "empty string should return 0"}, + + // Error cases + {"-30s", 0, true, "negative duration should error"}, + {"abc", 0, true, "invalid format should error"}, + {"1y", 0, true, "unsupported unit should error"}, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + result, err := parseTTL(tt.input) + + if tt.hasError { + if err == nil { + t.Errorf("parseTTL(%q) expected error but got none", tt.input) + } + } else { + if err != nil { + t.Errorf("parseTTL(%q) unexpected error: %v", tt.input, err) + } + if result != tt.expected { + t.Errorf("parseTTL(%q) = %d, expected %d", tt.input, result, tt.expected) + } + } + }) + } +} diff --git a/plugin/etcd/ttl_test.go b/plugin/etcd/ttl_test.go new file mode 100644 index 000000000..6dd346bb7 --- /dev/null +++ b/plugin/etcd/ttl_test.go @@ -0,0 +1,112 @@ +package etcd + +import ( + "testing" + + "github.com/coredns/coredns/plugin/etcd/msg" + "go.etcd.io/etcd/api/v3/mvccpb" +) + +func TestTTL(t *testing.T) { + tests := []struct { + name string + leaseID int64 + serviceTTL uint32 + minLeaseTTL uint32 + maxLeaseTTL uint32 + hasClient bool + expectedTTL uint32 + }{ + { + name: "no client, large lease ID falls back to default", + leaseID: 0x12345678FFFFFFFF, // Large lease ID that would cause issues + serviceTTL: 0, + minLeaseTTL: 0, + maxLeaseTTL: 0, + hasClient: false, + expectedTTL: defaultTTL, + }, + { + name: "no client, zero lease ID falls back to default", + leaseID: 0, + serviceTTL: 0, + minLeaseTTL: 0, + maxLeaseTTL: 0, + hasClient: false, + expectedTTL: defaultTTL, + }, + { + name: "no client, service TTL takes precedence", + leaseID: 120, + serviceTTL: 300, + minLeaseTTL: 0, + maxLeaseTTL: 0, + hasClient: false, + expectedTTL: 300, + }, + { + name: "no client, smaller service TTL wins", + leaseID: 600, + serviceTTL: 120, + minLeaseTTL: 0, + maxLeaseTTL: 0, + hasClient: false, + expectedTTL: 120, + }, + { + name: "custom bounds, no client", + leaseID: 0x12345678FFFFFFFF, + serviceTTL: 0, + minLeaseTTL: 60, // 1 minute + maxLeaseTTL: 3600, // 1 hour + hasClient: false, + expectedTTL: defaultTTL, + }, + { + name: "zero service TTL with lease ID", + leaseID: 600, + serviceTTL: 0, + minLeaseTTL: 0, + maxLeaseTTL: 0, + hasClient: false, + expectedTTL: defaultTTL, + }, + { + name: "both zero, falls back to default", + leaseID: 0, + serviceTTL: 0, + minLeaseTTL: 0, + maxLeaseTTL: 0, + hasClient: false, + expectedTTL: defaultTTL, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create Etcd instance with test configuration + e := &Etcd{ + MinLeaseTTL: tt.minLeaseTTL, + MaxLeaseTTL: tt.maxLeaseTTL, + } + + // Create test data + kv := &mvccpb.KeyValue{ + Key: []byte("/test/service"), + Value: []byte(`{"host": "test.example.com"}`), + Lease: tt.leaseID, + } + + serv := &msg.Service{ + Host: "test.example.com", + TTL: tt.serviceTTL, + } + + resultingTTL := e.TTL(kv, serv) + + if resultingTTL != tt.expectedTTL { + t.Errorf("TTL() = %d, expected %d", resultingTTL, tt.expectedTTL) + } + }) + } +}