├── .gitignore ├── LICENSE ├── README.md ├── cf-tlsa-acmesh.go └── go.mod /.gitignore: -------------------------------------------------------------------------------- 1 | # Editors 2 | .idea/ 3 | .vscode/ 4 | 5 | # Executables 6 | cf-tlsa-acmesh 7 | cf-tlsa-acmesh.exe 8 | cf-tlsa-acmesh-x86-64 9 | cf-tlsa-acmesh-arm64 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Erik Junsved 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cf-tlsa-acmesh 2 | 3 | This is a simple Go program that lets you automate the updating of TLSA DNS records with the Cloudflare v4 API from [acme.sh](https://github.com/acmesh-official/acme.sh) generated keys, including the rollover (next) key generated by passing `--force-new-domain-key` to `acme.sh`. This is useful for configuring [DANE](https://en.wikipedia.org/wiki/DNS-based_Authentication_of_Named_Entities) when setting up an SMTP server. 4 | 5 | I use this together with the [Maddy Mail Server](https://maddy.email/) to self-host my email with good deliverability. 6 | 7 | > **NOTE:** This program is hardcoded to use port 25 and TCP as the protocol for the record name, but this can easily be changed by modifying the `port` and `protocol` variable in the source code. 8 | 9 | ## Usage 10 | 11 | ### Installation 12 | 13 | #### Linux x86-64 14 | This requires curl. 15 | 16 | Run this command with elevated privileges (for example, with the help of Sudo): 17 | ```shell 18 | sudo sh -c 'curl -LJ https://github.com/nixigaj/cf-tlsa-acmesh/releases/latest/download/cf-tlsa-acmesh-x86-64 -o /usr/local/bin/cf-tlsa-acmesh && chmod +x /usr/local/bin/cf-tlsa-acmesh' 19 | ``` 20 | 21 | #### Linux arm64 22 | This requires curl. 23 | 24 | Run this command with elevated privileges (for example, with the help of Sudo): 25 | ```shell 26 | sudo sh -c 'curl -LJ https://github.com/nixigaj/cf-tlsa-acmesh/releases/latest/download/cf-tlsa-acmesh-arm64 -o /usr/local/bin/cf-tlsa-acmesh && chmod +x /usr/local/bin/cf-tlsa-acmesh' 27 | ``` 28 | 29 | #### Other UNIX-like systems 30 | This requires Git and Go. 31 | 32 | ```shell 33 | git clone https://github.com/nixigaj/cf-tlsa-acmesh 34 | cd cf-tlsa-acmesh 35 | go build -ldflags="-s -w" -o cf-tlsa-acmesh 36 | ``` 37 | 38 | Install the generated executable by copying it to `/usr/local/bin/cf-tlsa-acmesh` (this requires elevated privileges). 39 | 40 | ```shell 41 | cp ./cf-tlsa-acmesh /usr/local/bin/cf-tlsa-acmesh 42 | ``` 43 | 44 | ### Example setup with acme.sh 45 | 46 | 1. Go to Cloudflare and obtain your zone ID for the domain. Generate a user API token with the Zone.DNS permissions. 47 | 48 | 2. Create a short shell script for the acme.sh `--reloadcmd` parameter, such as `~/.acme.sh/scripts/reloadcmd-mx1-example-com.sh`, and set the necessary environment variables: 49 | 50 | ```shell 51 | #!/bin/sh 52 | 53 | # Set environment variables 54 | export KEY_FILE=~/.acme.sh/mx1.example.com_ecc/mx1.example.com.key 55 | export KEY_FILE_NEXT=~/.acme.sh/mx1.example.com_ecc/mx1.example.com.key.next 56 | export ZONE_ID= 57 | export API_TOKEN= 58 | export DOMAIN=mx1.example.com 59 | 60 | # Execute the command 61 | /usr/local/bin/cf-tlsa-acmesh 62 | ``` 63 | 64 | 3. Issue an acme.sh certificate with the following command: 65 | 66 | ```shell 67 | env \ 68 | CF_Token= \ 69 | CF_Account_ID= \ 70 | CF_Zone_ID= \ 71 | ~/.acme.sh/acme.sh \ 72 | --issue \ 73 | --server letsencrypt \ 74 | --force \ 75 | --always-force-new-domain-key \ 76 | --dns dns_cf \ 77 | --reloadcmd '/bin/sh ~/.acme.sh/scripts/reloadcmd-mx1-example-com.sh' \ 78 | -d mx1.example.com 79 | ``` 80 | 81 | Ensure that you include `--always-force-new-domain-key` to generate a rollover (next) key. Confirm that the `--reloadcmd` parameter points to the correct script. 82 | 83 | 4. Run the `~/.acme.sh/scripts/reloadcmd-mx1-example-com.sh` script manually once to generate the initial DNS records and verify that everything works. You can run the script multiple times; it only updates DNS records when necessary and is self-healing provided the `ZONE_ID`, `API_TOKEN` and `DOMAIN` environment variables are set correctly. 84 | 85 | 5. For testing, use [Internet.nl's email test](https://internet.nl/test-mail/) to ensure that DANE and its rollover scheme are set up correctly, as you can see below. 86 | 87 | ![Screenshot from Internet.nl](https://nixigaj.github.io/media/cf-tlsa-acmesh/internet-nl-screenshot.png) 88 | 89 | ## License 90 | All files in this repository are licensed under the [MIT License](LICENSE). 91 | -------------------------------------------------------------------------------- /cf-tlsa-acmesh.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | // Copyright (c) 2023 Erik Junsved 3 | 4 | package main 5 | 6 | import ( 7 | "bytes" 8 | "crypto/sha256" 9 | "crypto/x509" 10 | "encoding/hex" 11 | "encoding/json" 12 | "encoding/pem" 13 | "fmt" 14 | "io" 15 | "log" 16 | "net/http" 17 | "os" 18 | ) 19 | 20 | const ( 21 | cloudflareAPI = "https://api.cloudflare.com/client/v4/zones/" 22 | port = 25 23 | protocol = "tcp" 24 | 25 | // If the values below are modified, 26 | // the generateCert function also needs 27 | // to be modified to reflect the changes. 28 | usage = 3 29 | selector = 1 30 | matchingType = 1 31 | ) 32 | 33 | type tlsaRecordsResponse struct { 34 | Result []tlsaRecord `json:"result"` 35 | } 36 | 37 | type tlsaRecord struct { 38 | ID string `json:"id"` 39 | Data tlsaData `json:"data"` 40 | } 41 | 42 | type tlsaData struct { 43 | Certificate string `json:"certificate"` 44 | MatchingType int `json:"matching_type"` 45 | Selector int `json:"selector"` 46 | Usage int `json:"usage"` 47 | } 48 | 49 | func main() { 50 | requiredEnvVars := []string{"KEY_FILE", "KEY_FILE_NEXT", "ZONE_ID", "API_TOKEN", "DOMAIN"} 51 | for _, envVar := range requiredEnvVars { 52 | if os.Getenv(envVar) == "" { 53 | log.Fatalln("Fatal:", envVar, "environment variable is not defined") 54 | } 55 | } 56 | 57 | cert, err := generateCert(os.Getenv("KEY_FILE")) 58 | if err != nil { 59 | log.Fatalln("Fatal: failed to generate current cert:", err) 60 | } 61 | 62 | certNext, err := generateCert(os.Getenv("KEY_FILE_NEXT")) 63 | if err != nil { 64 | log.Fatalln("Fatal: failed to generate next cert:", err) 65 | } 66 | 67 | log.Println("Current cert:", cert) 68 | log.Println("Next cert:", certNext) 69 | 70 | tlsaRecords, err := getTLSARecords() 71 | if err != nil { 72 | log.Fatalln("Fatal: failed to get TLSA records:", err) 73 | } 74 | 75 | for i, record := range tlsaRecords { 76 | log.Printf("DNS record %d: ID: %s, cert: %s\n", i+1, record.ID, record.Data.Certificate) 77 | } 78 | 79 | if len(tlsaRecords) != 2 { 80 | log.Println("Incorrect number of DNS entries. Deleting them and generating new ones.") 81 | 82 | err = deleteAll(tlsaRecords) 83 | if err != nil { 84 | log.Fatalln("Fatal: failed to delete all TLSA recors:", err) 85 | } 86 | 87 | err = addRequest(certNext) 88 | if err != nil { 89 | log.Fatalln("Fatal: failed to add TLSA record for current cert:", err) 90 | } 91 | 92 | err = addRequest(cert) 93 | if err != nil { 94 | log.Fatalln("Fatal: failed to add TLSA record for next cert:", err) 95 | } 96 | 97 | os.Exit(0) 98 | } 99 | 100 | switch { 101 | case (checkData(tlsaRecords[0], cert) && checkData(tlsaRecords[1], certNext)) || 102 | (checkData(tlsaRecords[0], certNext) && checkData(tlsaRecords[1], cert)): 103 | log.Println("Nothing to do!") 104 | case checkData(tlsaRecords[0], cert): 105 | err = modifyRequest(certNext, tlsaRecords[1].ID) 106 | case checkData(tlsaRecords[0], certNext): 107 | err = modifyRequest(cert, tlsaRecords[1].ID) 108 | case checkData(tlsaRecords[1], cert): 109 | err = modifyRequest(certNext, tlsaRecords[0].ID) 110 | case checkData(tlsaRecords[1], certNext): 111 | err = modifyRequest(cert, tlsaRecords[0].ID) 112 | default: 113 | err = modifyRequest(certNext, tlsaRecords[1].ID) 114 | if err != nil { 115 | break 116 | } 117 | err = modifyRequest(cert, tlsaRecords[0].ID) 118 | } 119 | if err != nil { 120 | log.Fatalln("Fatal: failed to modify TLSA records:", err) 121 | } 122 | 123 | os.Exit(0) 124 | } 125 | 126 | func getTLSARecords() ([]tlsaRecord, error) { 127 | zoneID := os.Getenv("ZONE_ID") 128 | authToken := os.Getenv("API_TOKEN") 129 | domain := os.Getenv("DOMAIN") 130 | 131 | url := fmt.Sprintf("%s%s/dns_records?name=_%d._%s.%s", cloudflareAPI, zoneID, port, protocol, domain) 132 | 133 | req, err := http.NewRequest("GET", url, nil) 134 | if err != nil { 135 | return nil, fmt.Errorf("failed to create HTTP request: %v", err) 136 | } 137 | 138 | req.Header.Set("Authorization", "Bearer "+authToken) 139 | 140 | client := &http.Client{} 141 | resp, err := client.Do(req) 142 | if err != nil { 143 | return nil, fmt.Errorf("failed to make HTTP request: %v", err) 144 | } 145 | defer func(Body io.ReadCloser) { 146 | err := Body.Close() 147 | if err != nil { 148 | log.Println("Error closing HTTP body", err) 149 | } 150 | }(resp.Body) 151 | 152 | body, err := io.ReadAll(resp.Body) 153 | if err != nil { 154 | return nil, fmt.Errorf("failed reading response body: %v", err) 155 | } 156 | 157 | if resp.StatusCode != http.StatusOK { 158 | return nil, fmt.Errorf("recieved %d HTTP response status code for GET request, response body: %s", resp.StatusCode, string(body)) 159 | } 160 | 161 | var response tlsaRecordsResponse 162 | err = json.Unmarshal(body, &response) 163 | if err != nil { 164 | return nil, fmt.Errorf("failed to decode JSON response: %v", err) 165 | } 166 | 167 | return response.Result, nil 168 | } 169 | 170 | func generateCert(keyPath string) (string, error) { 171 | keyBytes, err := os.ReadFile(keyPath) 172 | if err != nil { 173 | return "", fmt.Errorf("failed to read key file: %v", err) 174 | } 175 | 176 | block, _ := pem.Decode(keyBytes) 177 | if block == nil { 178 | return "", fmt.Errorf("failed to decode PEM block from key file") 179 | } 180 | 181 | key, err := x509.ParseECPrivateKey(block.Bytes) 182 | if err != nil { 183 | return "", fmt.Errorf("failed to parse private key: %v", err) 184 | } 185 | 186 | publicKeyBytes, err := x509.MarshalPKIXPublicKey(&key.PublicKey) 187 | if err != nil { 188 | return "", fmt.Errorf("failed to marshal public key: %v", err) 189 | } 190 | 191 | hash := sha256.New() 192 | hash.Write(publicKeyBytes) 193 | hashSum := hash.Sum(nil) 194 | 195 | return hex.EncodeToString(hashSum), nil 196 | } 197 | 198 | func deleteAll(tlsaRecords []tlsaRecord) error { 199 | zoneID, authToken := os.Getenv("ZONE_ID"), os.Getenv("API_TOKEN") 200 | 201 | for _, record := range tlsaRecords { 202 | log.Println("Deleting DNS record:", record.ID) 203 | url := cloudflareAPI + zoneID + "/dns_records/" + record.ID 204 | resp, err := makeHTTPRequest("DELETE", url, authToken, nil) 205 | err = handleResponse(resp, err, "DELETE") 206 | if err != nil { 207 | return err 208 | } 209 | } 210 | 211 | return nil 212 | } 213 | 214 | func addRequest(hash string) error { 215 | log.Println("Adding DNS record with hash:", hash) 216 | 217 | zoneID, authToken, domain := os.Getenv("ZONE_ID"), os.Getenv("API_TOKEN"), os.Getenv("DOMAIN") 218 | url := cloudflareAPI + zoneID + "/dns_records" 219 | 220 | payload := fmt.Sprintf( 221 | `{"type":"TLSA","name":"_%d._%s.%s","data":{"usage":%d,"selector":%d,"matching_type":%d,"certificate":"%s"}}`, 222 | port, protocol, domain, usage, selector, matchingType, hash) 223 | 224 | resp, err := makeHTTPRequest("POST", url, authToken, []byte(payload)) 225 | return handleResponse(resp, err, "POST") 226 | } 227 | 228 | func modifyRequest(hash, id string) error { 229 | log.Println("Modifying DNS record:", id, "with hash:", hash) 230 | 231 | zoneID, authToken, domain := os.Getenv("ZONE_ID"), os.Getenv("API_TOKEN"), os.Getenv("DOMAIN") 232 | url := cloudflareAPI + zoneID + "/dns_records/" + id 233 | 234 | payload := fmt.Sprintf( 235 | `{"type":"TLSA","name":"_%d._%s.%s","data":{"usage":%d,"selector":%d,"matching_type":%d,"certificate":"%s"}}`, 236 | port, protocol, domain, usage, selector, matchingType, hash) 237 | 238 | resp, err := makeHTTPRequest("PUT", url, authToken, []byte(payload)) 239 | return handleResponse(resp, err, "PUT") 240 | } 241 | 242 | func makeHTTPRequest(method, url, authToken string, payload []byte) (*http.Response, error) { 243 | req, err := http.NewRequest(method, url, bytes.NewBuffer(payload)) 244 | if err != nil { 245 | return nil, err 246 | } 247 | 248 | req.Header.Set("Authorization", "Bearer "+authToken) 249 | req.Header.Set("Content-Type", "application/json") 250 | 251 | client := &http.Client{} 252 | return client.Do(req) 253 | } 254 | 255 | func handleResponse(resp *http.Response, err error, action string) error { 256 | if err != nil { 257 | return err 258 | } 259 | defer func(Body io.ReadCloser) { 260 | err := Body.Close() 261 | if err != nil { 262 | log.Println("Error closing HTTP body:", err) 263 | } 264 | }(resp.Body) 265 | 266 | body, err := io.ReadAll(resp.Body) 267 | if err != nil { 268 | return fmt.Errorf("failed reading response body: %v", err) 269 | } 270 | 271 | if resp.StatusCode != http.StatusOK { 272 | return fmt.Errorf("recieved %d HTTP response status code for %s request, response body: %s", resp.StatusCode, action, string(body)) 273 | } 274 | 275 | return nil 276 | } 277 | 278 | func checkData(record tlsaRecord, hash string) (correct bool) { 279 | return record.Data.Usage == usage && 280 | record.Data.Selector == selector && 281 | record.Data.MatchingType == matchingType && 282 | record.Data.Certificate == hash 283 | } 284 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/nixigaj/cf-tlsa-acmesh 2 | 3 | go 1.16 4 | --------------------------------------------------------------------------------