Merge pull request #1828 from vsychov/txt-encryption

Try #3: Support encrypted DNS txt records
This commit is contained in:
Kubernetes Prow Robot 2023-05-09 10:47:57 -07:00 committed by GitHub
commit f56e2f6198
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 423 additions and 52 deletions

View File

@ -13,3 +13,68 @@ The controller will try to create the "new format" TXT records if they are not p
Later on, the old format will be dropped and only the new format will be kept (<record_type>-<endpoint_name>). Later on, the old format will be dropped and only the new format will be kept (<record_type>-<endpoint_name>).
Cleanup will be done by controller itself. Cleanup will be done by controller itself.
### Encryption of TXT Records
TXT records may contain sensitive information, such as the internal ingress name or namespace, which attackers could exploit to gather information about your infrastructure.
By encrypting TXT records, you can protect this information from unauthorized access. It is strongly recommended to encrypt all TXT records to prevent potential security breaches.
To enable encryption of TXT records, you can use the following parameters:
- `--txt-encrypt-enabled=true`
- `--txt-encrypt-aes-key=32bytesKey` (used for AES-256-GCM encryption and should be exactly 32 bytes)
Note that the key used for encryption should be a secure key and properly managed to ensure the security of your TXT records.
### Generating TXT encryption AES key
Python
```python
python -c 'import os,base64; print(base64.urlsafe_b64encode(os.urandom(32)).decode())'
```
Bash
```shell
dd if=/dev/urandom bs=32 count=1 2>/dev/null | base64 | tr -d -- '\n' | tr -- '+/' '-_'; echo
```
OpenSSL
```shell
openssl rand -base64 32 | tr -- '+/' '-_'
```
PowerShell
```powershell
# Add System.Web assembly to session, just in case
Add-Type -AssemblyName System.Web
[Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes([System.Web.Security.Membership]::GeneratePassword(32,4))).Replace("+","-").Replace("/","_")
```
Terraform
```hcl
resource "random_password" "txt_key" {
length = 32
override_special = "-_"
}
```
### Manually Encrypt/Decrypt TXT Records
In some cases, you may need to edit labels generated by External-DNS, and in such cases, you can use simple Golang code to do that.
```go
package main
import (
"fmt"
"sigs.k8s.io/external-dns/endpoint"
)
func main() {
key := []byte("testtesttesttesttesttesttesttest")
encrypted, _ := endpoint.EncryptText(
"heritage=external-dns,external-dns/owner=example,external-dns/resource=ingress/default/example",
key,
nil,
)
decrypted, _, _ := endpoint.DecryptText(encrypted, key)
fmt.Println(decrypted)
}
```

134
endpoint/crypto.go Normal file
View File

@ -0,0 +1,134 @@
/*
Copyright 2017 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package endpoint
import (
"bytes"
"compress/gzip"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"fmt"
"io"
log "github.com/sirupsen/logrus"
)
// EncryptText gzip input data and encrypts it using the supplied AES key
func EncryptText(text string, aesKey []byte, nonceEncoded []byte) (string, error) {
block, err := aes.NewCipher(aesKey)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if nonceEncoded == nil {
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
} else {
if _, err = base64.StdEncoding.Decode(nonce, nonceEncoded); err != nil {
return "", err
}
}
data, err := compressData([]byte(text))
if err != nil {
return "", err
}
cipherData := gcm.Seal(nonce, nonce, data, nil)
return base64.StdEncoding.EncodeToString(cipherData), nil
}
// DecryptText decrypt gziped data using a supplied AES encryption key ang ungzip it
// in case of decryption failed, will return original input and decryption error
func DecryptText(text string, aesKey []byte) (decryptResult string, encryptNonce string, err error) {
block, err := aes.NewCipher(aesKey)
if err != nil {
return "", "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", "", err
}
nonceSize := gcm.NonceSize()
data, err := base64.StdEncoding.DecodeString(text)
if err != nil {
return "", "", err
}
if len(data) <= nonceSize {
return "", "", fmt.Errorf("the encoded data from text %#v is shorter than %#v bytes and can't be decoded", text, nonceSize)
}
nonce, ciphertext := data[:nonceSize], data[nonceSize:]
plaindata, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", "", err
}
plaindata, err = decompressData(plaindata)
if err != nil {
log.Debugf("Failed to decompress data based on the base64 encoded text %#v. Got error %#v.", text, err)
return "", "", err
}
return string(plaindata), base64.StdEncoding.EncodeToString(nonce), nil
}
// decompressData gzip compressed data
func decompressData(data []byte) (resData []byte, err error) {
gz, err := gzip.NewReader(bytes.NewBuffer(data))
if err != nil {
return nil, err
}
defer gz.Close()
var b bytes.Buffer
if _, err = b.ReadFrom(gz); err != nil {
return nil, err
}
return b.Bytes(), nil
}
// compressData by gzip, for minify data stored in registry
func compressData(data []byte) (compressedData []byte, err error) {
var b bytes.Buffer
gz, err := gzip.NewWriterLevel(&b, gzip.BestCompression)
if err != nil {
return nil, err
}
defer gz.Close()
if _, err = gz.Write(data); err != nil {
return nil, err
}
if err = gz.Flush(); err != nil {
return nil, err
}
if err = gz.Close(); err != nil {
return nil, err
}
return b.Bytes(), nil
}

58
endpoint/crypto_test.go Normal file
View File

@ -0,0 +1,58 @@
/*
Copyright 2017 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package endpoint
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestEncrypt(t *testing.T) {
// Verify that text encryption and decryption works
aesKey := []byte("s%zF`.*'5`9.AhI2!B,.~hmbs^.*TL?;")
plaintext := "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum."
encryptedtext, err := EncryptText(plaintext, aesKey, nil)
require.NoError(t, err)
decryptedtext, _, err := DecryptText(encryptedtext, aesKey)
require.NoError(t, err)
if plaintext != decryptedtext {
t.Errorf("Original plain text %#v differs from the resulting decrypted text %#v", plaintext, decryptedtext)
}
// Verify that decrypt returns an error and empty data if wrong AES encryption key is used
decryptedtext, _, err = DecryptText(encryptedtext, []byte("s'J!jD`].LC?g&Oa11AgTub,j48ts/96"))
require.Error(t, err)
if decryptedtext != "" {
t.Error("Data decryption failed, empty string should be as result")
}
// Verify that decrypt returns an error and empty data if unencrypted input is is supplied
decryptedtext, _, err = DecryptText(plaintext, aesKey)
require.Error(t, err)
if decryptedtext != "" {
t.Errorf("Data decryption failed, empty string should be as result")
}
// Verify that a known encrypted text is decrypted to what is expected
encryptedtext = "0Mfzf6wsN8llrfX0ucDZ6nlc2+QiQfKKedjPPLu5atb2I35L9nUZeJcCnuLVW7CVW3K0h94vSuBLdXnMrj8Vcm0M09shxaoF48IcCpD03XtQbKXqk2hPbsW6+JybvplHIQGr16/PcjUSObGmR9yjf38+qEltApkKvrPjsyw43BX4eE10rL0Bln33UJD7/w+zazRDPFlAcbGtkt0ETKHnvyB3/aCddLipvrhjCXj2ZY/ktRF6h716kJRgXU10dCIQHFYU45MIdxI+k10HK3yZqhI2V0Gp2xjrFV/LRQ7/OS9SFee4asPWUYxbCEsnOzp8qc0dCPFSo1dtADzWnUZnsAcbnjtudT4milfLJc5CxDk1v3ykqQ/ajejwHjWQ7b8U6AsTErbezfdcqrb5IzkLgHb5TosnfrdDmNc9GcKfpsrCHbVY8KgNwMVdtwavLv7d9WM6sooUlZ3t0sABGkzagXQmPRvwLnkSOlie5XrnzWo8/8/4UByLga29CaXO"
decryptedtext, _, err = DecryptText(encryptedtext, aesKey)
require.NoError(t, err)
if decryptedtext != plaintext {
t.Error("Decryption of text didn't result in expected plaintext result.")
}
}

View File

@ -17,6 +17,8 @@ limitations under the License.
package endpoint package endpoint
import ( import (
log "github.com/sirupsen/logrus"
"errors" "errors"
"fmt" "fmt"
"sort" "sort"
@ -41,6 +43,9 @@ const (
// DualstackLabelKey is the name of the label that identifies dualstack endpoints // DualstackLabelKey is the name of the label that identifies dualstack endpoints
DualstackLabelKey = "dualstack" DualstackLabelKey = "dualstack"
// txtEncryptionNonce label for keep same nonce for same txt records, for prevent different result of encryption for same txt record, it can cause issues for some providers
txtEncryptionNonce = "txt-encryption-nonce"
) )
// Labels store metadata related to the endpoint // Labels store metadata related to the endpoint
@ -55,7 +60,7 @@ func NewLabels() Labels {
// NewLabelsFromString constructs endpoints labels from a provided format string // NewLabelsFromString constructs endpoints labels from a provided format string
// if heritage set to another value is found then error is returned // if heritage set to another value is found then error is returned
// no heritage automatically assumes is not owned by external-dns and returns invalidHeritage error // no heritage automatically assumes is not owned by external-dns and returns invalidHeritage error
func NewLabelsFromString(labelText string) (Labels, error) { func NewLabelsFromStringPlain(labelText string) (Labels, error) {
endpointLabels := map[string]string{} endpointLabels := map[string]string{}
labelText = strings.Trim(labelText, "\"") // drop quotes labelText = strings.Trim(labelText, "\"") // drop quotes
tokens := strings.Split(labelText, ",") tokens := strings.Split(labelText, ",")
@ -85,9 +90,26 @@ func NewLabelsFromString(labelText string) (Labels, error) {
return endpointLabels, nil return endpointLabels, nil
} }
// Serialize transforms endpoints labels into a external-dns recognizable format string func NewLabelsFromString(labelText string, aesKey []byte) (Labels, error) {
if len(aesKey) != 0 {
decryptedText, encryptionNonce, err := DecryptText(strings.Trim(labelText, "\""), aesKey)
//in case if we have decryption error, just try process original text
//decryption errors should be ignored here, because we can already have plain-text labels in registry
if err == nil {
labels, err := NewLabelsFromStringPlain(decryptedText)
if err == nil {
labels[txtEncryptionNonce] = encryptionNonce
}
return labels, err
}
}
return NewLabelsFromStringPlain(labelText)
}
// SerializePlain transforms endpoints labels into a external-dns recognizable format string
// withQuotes adds additional quotes // withQuotes adds additional quotes
func (l Labels) Serialize(withQuotes bool) string { func (l Labels) SerializePlain(withQuotes bool) string {
var tokens []string var tokens []string
tokens = append(tokens, fmt.Sprintf("heritage=%s", heritage)) tokens = append(tokens, fmt.Sprintf("heritage=%s", heritage))
var keys []string var keys []string
@ -104,3 +126,31 @@ func (l Labels) Serialize(withQuotes bool) string {
} }
return strings.Join(tokens, ",") return strings.Join(tokens, ",")
} }
// Serialize same to SerializePlain, but encrypt data, if encryption enabled
func (l Labels) Serialize(withQuotes bool, txtEncryptEnabled bool, aesKey []byte) string {
if !txtEncryptEnabled {
return l.SerializePlain(withQuotes)
}
var encryptionNonce []byte = nil
if extractedNonce, nonceExists := l[txtEncryptionNonce]; nonceExists {
encryptionNonce = []byte(extractedNonce)
delete(l, txtEncryptionNonce)
}
text := l.SerializePlain(false)
log.Debugf("Encrypt the serialized text %#v before returning it.", text)
var err error
text, err = EncryptText(text, aesKey, encryptionNonce)
if err != nil {
log.Fatalf("Failed to encrypt the text %#v using the encryption key %#v. Got error %#v.", text, aesKey, err)
}
if withQuotes {
text = fmt.Sprintf("\"%s\"", text)
}
log.Debugf("Serialized text after encryption is %#v.", text)
return text
}

View File

@ -25,14 +25,18 @@ import (
type LabelsSuite struct { type LabelsSuite struct {
suite.Suite suite.Suite
foo Labels aesKey []byte
fooAsText string foo Labels
fooAsTextWithQuotes string fooAsText string
barText string fooAsTextWithQuotes string
barTextAsMap Labels fooAsTextEncrypted string
noHeritageText string fooAsTextWithQuotesEncrypted string
wrongHeritageText string barText string
multipleHeritageText string // considered invalid barTextEncrypted string
barTextAsMap Labels
noHeritageText string
wrongHeritageText string
multipleHeritageText string // considered invalid
} }
func (suite *LabelsSuite) SetupTest() { func (suite *LabelsSuite) SetupTest() {
@ -40,48 +44,79 @@ func (suite *LabelsSuite) SetupTest() {
"owner": "foo-owner", "owner": "foo-owner",
"resource": "foo-resource", "resource": "foo-resource",
} }
suite.aesKey = []byte(")K_Fy|?Z.64#UuHm`}[d!GC%WJM_fs{_")
suite.fooAsText = "heritage=external-dns,external-dns/owner=foo-owner,external-dns/resource=foo-resource" suite.fooAsText = "heritage=external-dns,external-dns/owner=foo-owner,external-dns/resource=foo-resource"
suite.fooAsTextWithQuotes = fmt.Sprintf(`"%s"`, suite.fooAsText) suite.fooAsTextWithQuotes = fmt.Sprintf(`"%s"`, suite.fooAsText)
suite.fooAsTextEncrypted = `+lvP8q9KHJ6BS6O81i2Q6DLNdf2JSKy8j/gbZKviTZlGYj7q+yDoYMgkQ1hPn6urtGllM5bfFMcaaHto52otQtiOYrX8990J3kQqg4s47m3bH3Ejl8RSxSSuWJM3HJtPghQzYg0/LSOsdQ0=`
suite.fooAsTextWithQuotesEncrypted = fmt.Sprintf(`"%s"`, suite.fooAsTextEncrypted)
suite.barTextAsMap = map[string]string{ suite.barTextAsMap = map[string]string{
"owner": "bar-owner", "owner": "bar-owner",
"resource": "bar-resource", "resource": "bar-resource",
"new-key": "bar-new-key", "new-key": "bar-new-key",
} }
suite.barText = "heritage=external-dns,,external-dns/owner=bar-owner,external-dns/resource=bar-resource,external-dns/new-key=bar-new-key,random=stuff,no-equal-sign,," // also has some random gibberish suite.barText = "heritage=external-dns,,external-dns/owner=bar-owner,external-dns/resource=bar-resource,external-dns/new-key=bar-new-key,random=stuff,no-equal-sign,," // also has some random gibberish
suite.barTextEncrypted = "yi6vVATlgYN0enXBIupVK2atNUKtajofWMroWtvZjUanFZXlWvqjJPpjmMd91kv86bZj+syQEP0uR3TK6eFVV7oKFh/NxYyh238FjZ+25zlXW9TgbLoMalUNOkhKFdfXkLeeaqJjePB59t+kQBYX+ZEryK652asPs6M+xTIvtg07N7WWZ6SjJujm0RRISg=="
suite.noHeritageText = "external-dns/owner=random-owner" suite.noHeritageText = "external-dns/owner=random-owner"
suite.wrongHeritageText = "heritage=mate,external-dns/owner=random-owner" suite.wrongHeritageText = "heritage=mate,external-dns/owner=random-owner"
suite.multipleHeritageText = "heritage=mate,heritage=external-dns,external-dns/owner=random-owner" suite.multipleHeritageText = "heritage=mate,heritage=external-dns,external-dns/owner=random-owner"
} }
func (suite *LabelsSuite) TestSerialize() { func (suite *LabelsSuite) TestSerialize() {
suite.Equal(suite.fooAsText, suite.foo.Serialize(false), "should serializeLabel") suite.Equal(suite.fooAsText, suite.foo.SerializePlain(false), "should serializeLabel")
suite.Equal(suite.fooAsTextWithQuotes, suite.foo.Serialize(true), "should serializeLabel") suite.Equal(suite.fooAsTextWithQuotes, suite.foo.SerializePlain(true), "should serializeLabel")
suite.Equal(suite.fooAsText, suite.foo.Serialize(false, false, nil), "should serializeLabel")
suite.Equal(suite.fooAsTextWithQuotes, suite.foo.Serialize(true, false, nil), "should serializeLabel")
suite.Equal(suite.fooAsText, suite.foo.Serialize(false, false, suite.aesKey), "should serializeLabel")
suite.Equal(suite.fooAsTextWithQuotes, suite.foo.Serialize(true, false, suite.aesKey), "should serializeLabel")
suite.NotEqual(suite.fooAsText, suite.foo.Serialize(false, true, suite.aesKey), "should serializeLabel and encrypt")
suite.NotEqual(suite.fooAsTextWithQuotes, suite.foo.Serialize(true, true, suite.aesKey), "should serializeLabel and encrypt")
}
func (suite *LabelsSuite) TestEncryptionNonceReUsage() {
foo, err := NewLabelsFromString(suite.fooAsTextEncrypted, suite.aesKey)
suite.NoError(err, "should succeed for valid label text")
serialized := foo.Serialize(false, true, suite.aesKey)
suite.Equal(serialized, suite.fooAsTextEncrypted, "serialized result should be equal")
} }
func (suite *LabelsSuite) TestDeserialize() { func (suite *LabelsSuite) TestDeserialize() {
foo, err := NewLabelsFromString(suite.fooAsText) foo, err := NewLabelsFromStringPlain(suite.fooAsText)
suite.NoError(err, "should succeed for valid label text") suite.NoError(err, "should succeed for valid label text")
suite.Equal(suite.foo, foo, "should reconstruct original label map") suite.Equal(suite.foo, foo, "should reconstruct original label map")
foo, err = NewLabelsFromString(suite.fooAsTextWithQuotes) foo, err = NewLabelsFromStringPlain(suite.fooAsTextWithQuotes)
suite.NoError(err, "should succeed for valid label text") suite.NoError(err, "should succeed for valid label text")
suite.Equal(suite.foo, foo, "should reconstruct original label map") suite.Equal(suite.foo, foo, "should reconstruct original label map")
bar, err := NewLabelsFromString(suite.barText) foo, err = NewLabelsFromString(suite.fooAsTextEncrypted, suite.aesKey)
suite.NoError(err, "should succeed for valid encrypted label text")
for key, val := range suite.foo {
suite.Equal(val, foo[key], "should contains all keys from original label map")
}
foo, err = NewLabelsFromString(suite.fooAsTextWithQuotesEncrypted, suite.aesKey)
suite.NoError(err, "should succeed for valid encrypted label text")
for key, val := range suite.foo {
suite.Equal(val, foo[key], "should contains all keys from original label map")
}
bar, err := NewLabelsFromStringPlain(suite.barText)
suite.NoError(err, "should succeed for valid label text") suite.NoError(err, "should succeed for valid label text")
suite.Equal(suite.barTextAsMap, bar, "should reconstruct original label map") suite.Equal(suite.barTextAsMap, bar, "should reconstruct original label map")
noHeritage, err := NewLabelsFromString(suite.noHeritageText) bar, err = NewLabelsFromString(suite.barText, suite.aesKey)
suite.NoError(err, "should succeed for valid encrypted label text")
suite.Equal(suite.barTextAsMap, bar, "should reconstruct original label map")
noHeritage, err := NewLabelsFromStringPlain(suite.noHeritageText)
suite.Equal(ErrInvalidHeritage, err, "should fail if no heritage is found") suite.Equal(ErrInvalidHeritage, err, "should fail if no heritage is found")
suite.Nil(noHeritage, "should return nil") suite.Nil(noHeritage, "should return nil")
wrongHeritage, err := NewLabelsFromString(suite.wrongHeritageText) wrongHeritage, err := NewLabelsFromStringPlain(suite.wrongHeritageText)
suite.Equal(ErrInvalidHeritage, err, "should fail if wrong heritage is found") suite.Equal(ErrInvalidHeritage, err, "should fail if wrong heritage is found")
suite.Nil(wrongHeritage, "if error should return nil") suite.Nil(wrongHeritage, "if error should return nil")
multipleHeritage, err := NewLabelsFromString(suite.multipleHeritageText) multipleHeritage, err := NewLabelsFromStringPlain(suite.multipleHeritageText)
suite.Equal(ErrInvalidHeritage, err, "should fail if multiple heritage is found") suite.Equal(ErrInvalidHeritage, err, "should fail if multiple heritage is found")
suite.Nil(multipleHeritage, "if error should return nil") suite.Nil(multipleHeritage, "if error should return nil")
} }

View File

@ -383,7 +383,7 @@ func main() {
case "noop": case "noop":
r, err = registry.NewNoopRegistry(p) r, err = registry.NewNoopRegistry(p)
case "txt": case "txt":
r, err = registry.NewTXTRegistry(p, cfg.TXTPrefix, cfg.TXTSuffix, cfg.TXTOwnerID, cfg.TXTCacheInterval, cfg.TXTWildcardReplacement, cfg.ManagedDNSRecordTypes) r, err = registry.NewTXTRegistry(p, cfg.TXTPrefix, cfg.TXTSuffix, cfg.TXTOwnerID, cfg.TXTCacheInterval, cfg.TXTWildcardReplacement, cfg.ManagedDNSRecordTypes, cfg.TXTEncryptEnabled, []byte(cfg.TXTEncryptAESKey))
case "aws-sd": case "aws-sd":
r, err = registry.NewAWSSDRegistry(p.(*awssd.AWSSDProvider), cfg.TXTOwnerID) r, err = registry.NewAWSSDRegistry(p.(*awssd.AWSSDProvider), cfg.TXTOwnerID)
default: default:

View File

@ -148,6 +148,8 @@ type Config struct {
TXTOwnerID string TXTOwnerID string
TXTPrefix string TXTPrefix string
TXTSuffix string TXTSuffix string
TXTEncryptEnabled bool
TXTEncryptAESKey string
Interval time.Duration Interval time.Duration
MinEventSyncInterval time.Duration MinEventSyncInterval time.Duration
Once bool Once bool
@ -297,6 +299,8 @@ var defaultConfig = &Config{
TXTCacheInterval: 0, TXTCacheInterval: 0,
TXTWildcardReplacement: "", TXTWildcardReplacement: "",
MinEventSyncInterval: 5 * time.Second, MinEventSyncInterval: 5 * time.Second,
TXTEncryptEnabled: false,
TXTEncryptAESKey: "",
Interval: time.Minute, Interval: time.Minute,
Once: false, Once: false,
DryRun: false, DryRun: false,
@ -573,6 +577,8 @@ func (cfg *Config) ParseFlags(args []string) error {
app.Flag("txt-prefix", "When using the TXT registry, a custom string that's prefixed to each ownership DNS record (optional). Could contain record type template like '%{record_type}-prefix-'. Mutual exclusive with txt-suffix!").Default(defaultConfig.TXTPrefix).StringVar(&cfg.TXTPrefix) app.Flag("txt-prefix", "When using the TXT registry, a custom string that's prefixed to each ownership DNS record (optional). Could contain record type template like '%{record_type}-prefix-'. Mutual exclusive with txt-suffix!").Default(defaultConfig.TXTPrefix).StringVar(&cfg.TXTPrefix)
app.Flag("txt-suffix", "When using the TXT registry, a custom string that's suffixed to the host portion of each ownership DNS record (optional). Could contain record type template like '-%{record_type}-suffix'. Mutual exclusive with txt-prefix!").Default(defaultConfig.TXTSuffix).StringVar(&cfg.TXTSuffix) app.Flag("txt-suffix", "When using the TXT registry, a custom string that's suffixed to the host portion of each ownership DNS record (optional). Could contain record type template like '-%{record_type}-suffix'. Mutual exclusive with txt-prefix!").Default(defaultConfig.TXTSuffix).StringVar(&cfg.TXTSuffix)
app.Flag("txt-wildcard-replacement", "When using the TXT registry, a custom string that's used instead of an asterisk for TXT records corresponding to wildcard DNS records (optional)").Default(defaultConfig.TXTWildcardReplacement).StringVar(&cfg.TXTWildcardReplacement) app.Flag("txt-wildcard-replacement", "When using the TXT registry, a custom string that's used instead of an asterisk for TXT records corresponding to wildcard DNS records (optional)").Default(defaultConfig.TXTWildcardReplacement).StringVar(&cfg.TXTWildcardReplacement)
app.Flag("txt-encrypt-enabled", "When using the TXT registry, set if TXT records should be encrypted before stored (default: disabled)").BoolVar(&cfg.TXTEncryptEnabled)
app.Flag("txt-encrypt-aes-key", "When using the TXT registry, set TXT record decryption and encryption 32 byte aes key (required when --txt-encrypt=true)").Default(defaultConfig.TXTEncryptAESKey).StringVar(&cfg.TXTEncryptAESKey)
// Flags related to the main control loop // Flags related to the main control loop
app.Flag("txt-cache-interval", "The interval between cache synchronizations in duration format (default: disabled)").Default(defaultConfig.TXTCacheInterval.String()).DurationVar(&cfg.TXTCacheInterval) app.Flag("txt-cache-interval", "The interval between cache synchronizations in duration format (default: disabled)").Default(defaultConfig.TXTCacheInterval.String()).DurationVar(&cfg.TXTCacheInterval)

View File

@ -509,7 +509,7 @@ func (p *AWSSDProvider) DeleteService(service *sd.Service) error {
// convert ownerID string to service description format // convert ownerID string to service description format
label := endpoint.NewLabels() label := endpoint.NewLabels()
label[endpoint.OwnerLabelKey] = p.ownerID label[endpoint.OwnerLabelKey] = p.ownerID
label[endpoint.AWSSDDescriptionLabel] = label.Serialize(false) label[endpoint.AWSSDDescriptionLabel] = label.SerializePlain(false)
if strings.HasPrefix(aws.StringValue(service.Description), label[endpoint.AWSSDDescriptionLabel]) { if strings.HasPrefix(aws.StringValue(service.Description), label[endpoint.AWSSDDescriptionLabel]) {
log.Infof("Deleting service \"%s\"", *service.Name) log.Infof("Deleting service \"%s\"", *service.Name)

View File

@ -55,7 +55,7 @@ func (sdr *AWSSDRegistry) Records(ctx context.Context) ([]*endpoint.Endpoint, er
} }
for _, record := range records { for _, record := range records {
labels, err := endpoint.NewLabelsFromString(record.Labels[endpoint.AWSSDDescriptionLabel]) labels, err := endpoint.NewLabelsFromStringPlain(record.Labels[endpoint.AWSSDDescriptionLabel])
if err != nil { if err != nil {
// if we fail to parse the output then simply assume the endpoint is not managed by any instance of External DNS // if we fail to parse the output then simply assume the endpoint is not managed by any instance of External DNS
record.Labels = endpoint.NewLabels() record.Labels = endpoint.NewLabels()
@ -96,7 +96,7 @@ func (sdr *AWSSDRegistry) updateLabels(endpoints []*endpoint.Endpoint) {
ep.Labels = make(map[string]string) ep.Labels = make(map[string]string)
} }
ep.Labels[endpoint.OwnerLabelKey] = sdr.ownerID ep.Labels[endpoint.OwnerLabelKey] = sdr.ownerID
ep.Labels[endpoint.AWSSDDescriptionLabel] = ep.Labels.Serialize(false) ep.Labels[endpoint.AWSSDDescriptionLabel] = ep.Labels.SerializePlain(false)
} }
} }

View File

@ -52,15 +52,27 @@ type TXTRegistry struct {
// missingTXTRecords stores TXT records which are missing after the migration to the new format // missingTXTRecords stores TXT records which are missing after the migration to the new format
missingTXTRecords []*endpoint.Endpoint missingTXTRecords []*endpoint.Endpoint
// encrypt text records
txtEncryptEnabled bool
txtEncryptAESKey []byte
} }
const keySuffixAAAA = ":AAAA" const keySuffixAAAA = ":AAAA"
// NewTXTRegistry returns new TXTRegistry object // NewTXTRegistry returns new TXTRegistry object
func NewTXTRegistry(provider provider.Provider, txtPrefix, txtSuffix, ownerID string, cacheInterval time.Duration, txtWildcardReplacement string, managedRecordTypes []string) (*TXTRegistry, error) { func NewTXTRegistry(provider provider.Provider, txtPrefix, txtSuffix, ownerID string, cacheInterval time.Duration, txtWildcardReplacement string, managedRecordTypes []string, txtEncryptEnabled bool, txtEncryptAESKey []byte) (*TXTRegistry, error) {
if ownerID == "" { if ownerID == "" {
return nil, errors.New("owner id cannot be empty") return nil, errors.New("owner id cannot be empty")
} }
if len(txtEncryptAESKey) == 0 {
txtEncryptAESKey = nil
} else if len(txtEncryptAESKey) != 32 {
return nil, errors.New("the AES Encryption key must have a length of 32 bytes")
}
if txtEncryptEnabled && txtEncryptAESKey == nil {
return nil, errors.New("the AES Encryption key must be set when TXT record encryption is enabled")
}
if len(txtPrefix) > 0 && len(txtSuffix) > 0 { if len(txtPrefix) > 0 && len(txtSuffix) > 0 {
return nil, errors.New("txt-prefix and txt-suffix are mutual exclusive") return nil, errors.New("txt-prefix and txt-suffix are mutual exclusive")
@ -75,6 +87,8 @@ func NewTXTRegistry(provider provider.Provider, txtPrefix, txtSuffix, ownerID st
cacheInterval: cacheInterval, cacheInterval: cacheInterval,
wildcardReplacement: txtWildcardReplacement, wildcardReplacement: txtWildcardReplacement,
managedRecordTypes: managedRecordTypes, managedRecordTypes: managedRecordTypes,
txtEncryptEnabled: txtEncryptEnabled,
txtEncryptAESKey: txtEncryptAESKey,
}, nil }, nil
} }
@ -114,7 +128,7 @@ func (im *TXTRegistry) Records(ctx context.Context) ([]*endpoint.Endpoint, error
continue continue
} }
// We simply assume that TXT records for the registry will always have only one target. // We simply assume that TXT records for the registry will always have only one target.
labels, err := endpoint.NewLabelsFromString(record.Targets[0]) labels, err := endpoint.NewLabelsFromString(record.Targets[0], im.txtEncryptAESKey)
if err == endpoint.ErrInvalidHeritage { if err == endpoint.ErrInvalidHeritage {
// if no heritage is found or it is invalid // if no heritage is found or it is invalid
// case when value of txt record cannot be identified // case when value of txt record cannot be identified
@ -205,7 +219,7 @@ func (im *TXTRegistry) generateTXTRecord(r *endpoint.Endpoint) []*endpoint.Endpo
if r.RecordType != endpoint.RecordTypeAAAA { if r.RecordType != endpoint.RecordTypeAAAA {
// old TXT record format // old TXT record format
txt := endpoint.NewEndpoint(im.mapper.toTXTName(r.DNSName), endpoint.RecordTypeTXT, r.Labels.Serialize(true)) txt := endpoint.NewEndpoint(im.mapper.toTXTName(r.DNSName), endpoint.RecordTypeTXT, r.Labels.Serialize(true, im.txtEncryptEnabled, im.txtEncryptAESKey))
if txt != nil { if txt != nil {
txt.WithSetIdentifier(r.SetIdentifier) txt.WithSetIdentifier(r.SetIdentifier)
txt.Labels[endpoint.OwnedRecordLabelKey] = r.DNSName txt.Labels[endpoint.OwnedRecordLabelKey] = r.DNSName
@ -213,9 +227,8 @@ func (im *TXTRegistry) generateTXTRecord(r *endpoint.Endpoint) []*endpoint.Endpo
endpoints = append(endpoints, txt) endpoints = append(endpoints, txt)
} }
} }
// new TXT record format (containing record type) // new TXT record format (containing record type)
txtNew := endpoint.NewEndpoint(im.mapper.toNewTXTName(r.DNSName, r.RecordType), endpoint.RecordTypeTXT, r.Labels.Serialize(true)) txtNew := endpoint.NewEndpoint(im.mapper.toNewTXTName(r.DNSName, r.RecordType), endpoint.RecordTypeTXT, r.Labels.Serialize(true, im.txtEncryptEnabled, im.txtEncryptAESKey))
if txtNew != nil { if txtNew != nil {
txtNew.WithSetIdentifier(r.SetIdentifier) txtNew.WithSetIdentifier(r.SetIdentifier)
txtNew.Labels[endpoint.OwnedRecordLabelKey] = r.DNSName txtNew.Labels[endpoint.OwnedRecordLabelKey] = r.DNSName

View File

@ -46,20 +46,20 @@ func TestTXTRegistry(t *testing.T) {
func testTXTRegistryNew(t *testing.T) { func testTXTRegistryNew(t *testing.T) {
p := inmemory.NewInMemoryProvider() p := inmemory.NewInMemoryProvider()
_, err := NewTXTRegistry(p, "txt", "", "", time.Hour, "", []string{}) _, err := NewTXTRegistry(p, "txt", "", "", time.Hour, "", []string{}, false, nil)
require.Error(t, err) require.Error(t, err)
_, err = NewTXTRegistry(p, "", "txt", "", time.Hour, "", []string{}) _, err = NewTXTRegistry(p, "", "txt", "", time.Hour, "", []string{}, false, nil)
require.Error(t, err) require.Error(t, err)
r, err := NewTXTRegistry(p, "txt", "", "owner", time.Hour, "", []string{}) r, err := NewTXTRegistry(p, "txt", "", "owner", time.Hour, "", []string{}, false, nil)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, p, r.provider) assert.Equal(t, p, r.provider)
r, err = NewTXTRegistry(p, "", "txt", "owner", time.Hour, "", []string{}) r, err = NewTXTRegistry(p, "", "txt", "owner", time.Hour, "", []string{}, false, nil)
require.NoError(t, err) require.NoError(t, err)
_, err = NewTXTRegistry(p, "txt", "txt", "owner", time.Hour, "", []string{}) _, err = NewTXTRegistry(p, "txt", "txt", "owner", time.Hour, "", []string{}, false, nil)
require.Error(t, err) require.Error(t, err)
_, ok := r.mapper.(affixNameMapper) _, ok := r.mapper.(affixNameMapper)
@ -67,7 +67,17 @@ func testTXTRegistryNew(t *testing.T) {
assert.Equal(t, "owner", r.ownerID) assert.Equal(t, "owner", r.ownerID)
assert.Equal(t, p, r.provider) assert.Equal(t, p, r.provider)
r, err = NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}) aesKey := []byte(";k&l)nUC/33:{?d{3)54+,AD?]SX%yh^")
_, err = NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, false, nil)
require.NoError(t, err)
_, err = NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, false, aesKey)
require.NoError(t, err)
_, err = NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, true, nil)
require.Error(t, err)
r, err = NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, true, aesKey)
require.NoError(t, err) require.NoError(t, err)
_, ok = r.mapper.(affixNameMapper) _, ok = r.mapper.(affixNameMapper)
@ -203,13 +213,13 @@ func testTXTRegistryRecordsPrefixed(t *testing.T) {
}, },
} }
r, _ := NewTXTRegistry(p, "txt.", "", "owner", time.Hour, "wc", []string{}) r, _ := NewTXTRegistry(p, "txt.", "", "owner", time.Hour, "wc", []string{}, false, nil)
records, _ := r.Records(ctx) records, _ := r.Records(ctx)
assert.True(t, testutils.SameEndpoints(records, expectedRecords)) assert.True(t, testutils.SameEndpoints(records, expectedRecords))
// Ensure prefix is case-insensitive // Ensure prefix is case-insensitive
r, _ = NewTXTRegistry(p, "TxT.", "", "owner", time.Hour, "", []string{}) r, _ = NewTXTRegistry(p, "TxT.", "", "owner", time.Hour, "", []string{}, false, nil)
records, _ = r.Records(ctx) records, _ = r.Records(ctx)
assert.True(t, testutils.SameEndpointLabels(records, expectedRecords)) assert.True(t, testutils.SameEndpointLabels(records, expectedRecords))
@ -328,13 +338,13 @@ func testTXTRegistryRecordsSuffixed(t *testing.T) {
}, },
} }
r, _ := NewTXTRegistry(p, "", "-txt", "owner", time.Hour, "", []string{}) r, _ := NewTXTRegistry(p, "", "-txt", "owner", time.Hour, "", []string{}, false, nil)
records, _ := r.Records(ctx) records, _ := r.Records(ctx)
assert.True(t, testutils.SameEndpoints(records, expectedRecords)) assert.True(t, testutils.SameEndpoints(records, expectedRecords))
// Ensure prefix is case-insensitive // Ensure prefix is case-insensitive
r, _ = NewTXTRegistry(p, "", "-TxT", "owner", time.Hour, "", []string{}) r, _ = NewTXTRegistry(p, "", "-TxT", "owner", time.Hour, "", []string{}, false, nil)
records, _ = r.Records(ctx) records, _ = r.Records(ctx)
assert.True(t, testutils.SameEndpointLabels(records, expectedRecords)) assert.True(t, testutils.SameEndpointLabels(records, expectedRecords))
@ -429,7 +439,7 @@ func testTXTRegistryRecordsNoPrefix(t *testing.T) {
}, },
} }
r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}) r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, false, nil)
records, _ := r.Records(ctx) records, _ := r.Records(ctx)
assert.True(t, testutils.SameEndpoints(records, expectedRecords)) assert.True(t, testutils.SameEndpoints(records, expectedRecords))
@ -472,7 +482,7 @@ func testTXTRegistryApplyChangesWithPrefix(t *testing.T) {
newEndpointWithOwner("txt.cname-multiple.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "").WithSetIdentifier("test-set-2"), newEndpointWithOwner("txt.cname-multiple.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "").WithSetIdentifier("test-set-2"),
}, },
}) })
r, _ := NewTXTRegistry(p, "txt.", "", "owner", time.Hour, "", []string{}) r, _ := NewTXTRegistry(p, "txt.", "", "owner", time.Hour, "", []string{}, false, nil)
changes := &plan.Changes{ changes := &plan.Changes{
Create: []*endpoint.Endpoint{ Create: []*endpoint.Endpoint{
@ -561,7 +571,7 @@ func testTXTRegistryApplyChangesWithTemplatedPrefix(t *testing.T) {
p.ApplyChanges(ctx, &plan.Changes{ p.ApplyChanges(ctx, &plan.Changes{
Create: []*endpoint.Endpoint{}, Create: []*endpoint.Endpoint{},
}) })
r, _ := NewTXTRegistry(p, "prefix%{record_type}.", "", "owner", time.Hour, "", []string{}) r, _ := NewTXTRegistry(p, "prefix%{record_type}.", "", "owner", time.Hour, "", []string{}, false, nil)
changes := &plan.Changes{ changes := &plan.Changes{
Create: []*endpoint.Endpoint{ Create: []*endpoint.Endpoint{
newEndpointWithOwnerResource("new-record-1.test-zone.example.org", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, "", "ingress/default/my-ingress"), newEndpointWithOwnerResource("new-record-1.test-zone.example.org", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, "", "ingress/default/my-ingress"),
@ -605,7 +615,7 @@ func testTXTRegistryApplyChangesWithTemplatedSuffix(t *testing.T) {
p.OnApplyChanges = func(ctx context.Context, got *plan.Changes) { p.OnApplyChanges = func(ctx context.Context, got *plan.Changes) {
assert.Equal(t, ctxEndpoints, ctx.Value(provider.RecordsContextKey)) assert.Equal(t, ctxEndpoints, ctx.Value(provider.RecordsContextKey))
} }
r, _ := NewTXTRegistry(p, "", "-%{record_type}suffix", "owner", time.Hour, "", []string{}) r, _ := NewTXTRegistry(p, "", "-%{record_type}suffix", "owner", time.Hour, "", []string{}, false, nil)
changes := &plan.Changes{ changes := &plan.Changes{
Create: []*endpoint.Endpoint{ Create: []*endpoint.Endpoint{
newEndpointWithOwnerResource("new-record-1.test-zone.example.org", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, "", "ingress/default/my-ingress"), newEndpointWithOwnerResource("new-record-1.test-zone.example.org", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, "", "ingress/default/my-ingress"),
@ -671,7 +681,7 @@ func testTXTRegistryApplyChangesWithSuffix(t *testing.T) {
newEndpointWithOwner("cname-multiple-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "").WithSetIdentifier("test-set-2"), newEndpointWithOwner("cname-multiple-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "").WithSetIdentifier("test-set-2"),
}, },
}) })
r, _ := NewTXTRegistry(p, "", "-txt", "owner", time.Hour, "wildcard", []string{}) r, _ := NewTXTRegistry(p, "", "-txt", "owner", time.Hour, "wildcard", []string{}, false, nil)
changes := &plan.Changes{ changes := &plan.Changes{
Create: []*endpoint.Endpoint{ Create: []*endpoint.Endpoint{
@ -775,7 +785,7 @@ func testTXTRegistryApplyChangesNoPrefix(t *testing.T) {
newEndpointWithOwner("cname-foobar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("cname-foobar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""),
}, },
}) })
r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}) r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, false, nil)
changes := &plan.Changes{ changes := &plan.Changes{
Create: []*endpoint.Endpoint{ Create: []*endpoint.Endpoint{
@ -941,7 +951,7 @@ func testTXTRegistryMissingRecordsNoPrefix(t *testing.T) {
}, },
} }
r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "wc", []string{endpoint.RecordTypeCNAME, endpoint.RecordTypeA, endpoint.RecordTypeNS}) r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "wc", []string{endpoint.RecordTypeCNAME, endpoint.RecordTypeA, endpoint.RecordTypeNS}, false, nil)
records, _ := r.Records(ctx) records, _ := r.Records(ctx)
missingRecords := r.MissingRecords() missingRecords := r.MissingRecords()
@ -1045,7 +1055,7 @@ func testTXTRegistryMissingRecordsWithPrefix(t *testing.T) {
}, },
} }
r, _ := NewTXTRegistry(p, "txt.", "", "owner", time.Hour, "wc", []string{endpoint.RecordTypeCNAME, endpoint.RecordTypeA, endpoint.RecordTypeNS}) r, _ := NewTXTRegistry(p, "txt.", "", "owner", time.Hour, "wc", []string{endpoint.RecordTypeCNAME, endpoint.RecordTypeA, endpoint.RecordTypeNS}, false, nil)
records, _ := r.Records(ctx) records, _ := r.Records(ctx)
missingRecords := r.MissingRecords() missingRecords := r.MissingRecords()
@ -1199,7 +1209,7 @@ func TestNewTXTScheme(t *testing.T) {
newEndpointWithOwner("cname-foobar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("cname-foobar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""),
}, },
}) })
r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}) r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, false, nil)
changes := &plan.Changes{ changes := &plan.Changes{
Create: []*endpoint.Endpoint{ Create: []*endpoint.Endpoint{
@ -1275,7 +1285,7 @@ func TestGenerateTXT(t *testing.T) {
} }
p := inmemory.NewInMemoryProvider() p := inmemory.NewInMemoryProvider()
p.CreateZone(testZone) p.CreateZone(testZone)
r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}) r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, false, nil)
gotTXT := r.generateTXTRecord(record) gotTXT := r.generateTXTRecord(record)
assert.Equal(t, expectedTXT, gotTXT) assert.Equal(t, expectedTXT, gotTXT)
} }
@ -1294,7 +1304,7 @@ func TestGenerateTXTForAAAA(t *testing.T) {
} }
p := inmemory.NewInMemoryProvider() p := inmemory.NewInMemoryProvider()
p.CreateZone(testZone) p.CreateZone(testZone)
r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}) r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, false, nil)
gotTXT := r.generateTXTRecord(record) gotTXT := r.generateTXTRecord(record)
assert.Equal(t, expectedTXT, gotTXT) assert.Equal(t, expectedTXT, gotTXT)
} }
@ -1311,7 +1321,7 @@ func TestFailGenerateTXT(t *testing.T) {
expectedTXT := []*endpoint.Endpoint{} expectedTXT := []*endpoint.Endpoint{}
p := inmemory.NewInMemoryProvider() p := inmemory.NewInMemoryProvider()
p.CreateZone(testZone) p.CreateZone(testZone)
r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}) r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, false, nil)
gotTXT := r.generateTXTRecord(cnameRecord) gotTXT := r.generateTXTRecord(cnameRecord)
assert.Equal(t, expectedTXT, gotTXT) assert.Equal(t, expectedTXT, gotTXT)
} }