diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2b78e65 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# Editors +.idea/ +.vscode/ + +# Executables +cf-tlsa-acmesh +cf-tlsa-acmesh.exe +cf-tlsa-acmesh-x86-64 +cf-tlsa-acmesh-arm64 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8daf50b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Erik Junsved + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4a7b306 --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +# cf-tlsa-acmesh + +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. + +I use this together with the [Maddy Mail Server](https://maddy.email/) to self-host my email with good deliverability. + +> **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. + +## Usage + +### Installation + +#### Linux x86-64 +This requires curl. + +Run this command with elevated privileges (for example, with the help of Sudo): +```shell +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' +``` + +#### Linux arm64 +This requires curl. + +Run this command with elevated privileges (for example, with the help of Sudo): +```shell +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' +``` + +#### Other UNIX-like systems +This requires Git and Go. + +```shell +git clone https://github.com/nixigaj/cf-tlsa-acmesh +cd cf-tlsa-acmesh +go build -ldflags="-s -w" -o cf-tlsa-acmesh +``` + +Install the generated executable by copying it to `/usr/local/bin/cf-tlsa-acmesh` (this requires elevated privileges). + +```shell +cp ./cf-tlsa-acmesh /usr/local/bin/cf-tlsa-acmesh +``` + +### Example setup with acme.sh + +1. Go to Cloudflare and obtain your zone ID for the domain. Generate a user API token with the Zone.DNS permissions. + +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: + + ```shell + #!/bin/sh + + # Set environment variables + export KEY_FILE=~/.acme.sh/mx1.example.com_ecc/mx1.example.com.key + export KEY_FILE_NEXT=~/.acme.sh/mx1.example.com_ecc/mx1.example.com.key.next + export ZONE_ID= + export API_TOKEN= + export DOMAIN=mx1.example.com + + # Execute the command + /usr/local/bin/cf-tlsa-acmesh + ``` + +3. Issue an acme.sh certificate with the following command: + + ```shell + env \ + CF_Token= \ + CF_Account_ID= \ + CF_Zone_ID= \ + ~/.acme.sh/acme.sh \ + --issue \ + --server letsencrypt \ + --force \ + --always-force-new-domain-key \ + --dns dns_cf \ + --reloadcmd '/bin/sh ~/.acme.sh/scripts/reloadcmd-mx1-example-com.sh' \ + -d mx1.example.com + ``` + + 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. + +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. + +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. + + ![Screenshot from Internet.nl](https://nixigaj.github.io/media/cf-tlsa-acmesh/internet-nl-screenshot.png) + +## License +All files in this repository are licensed under the [MIT License](LICENSE). diff --git a/cf-tlsa-acmesh.go b/cf-tlsa-acmesh.go new file mode 100644 index 0000000..7887bca --- /dev/null +++ b/cf-tlsa-acmesh.go @@ -0,0 +1,263 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2023 Erik Junsved + +package main + +import ( + "bytes" + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "log" + "net/http" + "os" +) + +const ( + cloudflareAPI = "https://api.cloudflare.com/client/v4/zones/" + port = 25 + protocol = "tcp" + + // If the values below are modified, + // the generateCert function also needs + // to be modified to reflect the changes. + usage = 3 + selector = 1 + matchingType = 1 +) + +type tlsaRecordsResponse struct { + Result []tlsaRecord `json:"result"` +} + +type tlsaRecord struct { + ID string `json:"id"` + Data tlsaData `json:"data"` +} + +type tlsaData struct { + Certificate string `json:"certificate"` + MatchingType int `json:"matching_type"` + Selector int `json:"selector"` + Usage int `json:"usage"` +} + +func main() { + requiredEnvVars := []string{"KEY_FILE", "KEY_FILE_NEXT", "ZONE_ID", "API_TOKEN", "DOMAIN"} + for _, envVar := range requiredEnvVars { + if os.Getenv(envVar) == "" { + log.Println("Error:", envVar, "environment variable is not defined") + os.Exit(1) + } + } + + cert, err := generateCert(os.Getenv("KEY_FILE")) + if err != nil { + log.Println("Error generating cert:", err) + os.Exit(1) + } + + certNext, err := generateCert(os.Getenv("KEY_FILE_NEXT")) + if err != nil { + log.Println("Error generating next cert:", err) + os.Exit(1) + } + + log.Println("Current cert:", cert) + log.Println("Next cert:", certNext) + + tlsaRecords, err := getTLSARecords() + if err != nil { + log.Println("Error:", err) + return + } + + for i, record := range tlsaRecords { + log.Printf("DNS record %d: ID: %s, cert: %s\n", i+1, record.ID, record.Data.Certificate) + } + + if len(tlsaRecords) != 2 { + log.Println("Incorrect number of DNS entries. Deleting them and generating new ones.") + deleteAll(tlsaRecords) + addRequest(certNext) + addRequest(cert) + return + } + + if (checkData(tlsaRecords[0], cert) && checkData(tlsaRecords[1], certNext)) || + (checkData(tlsaRecords[0], certNext) && checkData(tlsaRecords[1], cert)) { + log.Println("Nothing to do!") + } else if checkData(tlsaRecords[0], cert) { + modifyRequest(certNext, tlsaRecords[1].ID) + } else if checkData(tlsaRecords[0], certNext) { + modifyRequest(cert, tlsaRecords[1].ID) + } else if checkData(tlsaRecords[1], cert) { + modifyRequest(certNext, tlsaRecords[0].ID) + } else if checkData(tlsaRecords[1], certNext) { + modifyRequest(cert, tlsaRecords[0].ID) + } else { + modifyRequest(certNext, tlsaRecords[1].ID) + modifyRequest(cert, tlsaRecords[0].ID) + } +} + +func getTLSARecords() ([]tlsaRecord, error) { + requiredEnvVars := []string{"ZONE_ID", "API_TOKEN", "DOMAIN"} + for _, envVar := range requiredEnvVars { + if os.Getenv(envVar) == "" { + return nil, fmt.Errorf("%s environment variable is not defined", envVar) + } + } + + zoneID := os.Getenv("ZONE_ID") + authToken := os.Getenv("API_TOKEN") + domain := os.Getenv("DOMAIN") + + url := fmt.Sprintf("%s%s/dns_records?name=_%d._%s.%s", cloudflareAPI, zoneID, port, protocol, domain) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP request: %v", err) + } + + req.Header.Set("Authorization", "Bearer "+authToken) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to make HTTP request: %v", err) + } + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + log.Println("Error closing HTTP body", err) + } + }(resp.Body) + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP request failed with status code: %s", resp.Status) + } + + var response tlsaRecordsResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("failed to decode JSON response: %v", err) + } + + return response.Result, nil +} + +func generateCert(keyPath string) (string, error) { + keyBytes, err := os.ReadFile(keyPath) + if err != nil { + return "", fmt.Errorf("failed to read key file: %v", err) + } + + block, _ := pem.Decode(keyBytes) + if block == nil { + return "", fmt.Errorf("failed to decode PEM block from key file") + } + + key, err := x509.ParseECPrivateKey(block.Bytes) + if err != nil { + return "", fmt.Errorf("failed to parse private key: %v", err) + } + + publicKeyBytes, err := x509.MarshalPKIXPublicKey(&key.PublicKey) + if err != nil { + return "", fmt.Errorf("failed to marshal public key: %v", err) + } + + hash := sha256.New() + hash.Write(publicKeyBytes) + hashSum := hash.Sum(nil) + + return hex.EncodeToString(hashSum), nil +} + +func deleteAll(tlsaRecords []tlsaRecord) { + zoneID, authToken := os.Getenv("ZONE_ID"), os.Getenv("API_TOKEN") + + for _, record := range tlsaRecords { + log.Println("Deleting DNS record:", record.ID) + url := cloudflareAPI + zoneID + "/dns_records/" + record.ID + resp, err := makeHTTPRequest("DELETE", url, authToken, nil) + handleResponse(resp, err, "DELETE") + } +} + +func addRequest(hash string) { + log.Println("Adding DNS record with hash:", hash) + + zoneID, authToken, domain := os.Getenv("ZONE_ID"), os.Getenv("API_TOKEN"), os.Getenv("DOMAIN") + url := cloudflareAPI + zoneID + "/dns_records" + + payload := fmt.Sprintf( + `{"type":"TLSA","name":"_%d._%s.%s","data":{"usage":%d,"selector":%d,"matching_type":%d,"certificate":"%s"}}`, + port, protocol, domain, usage, selector, matchingType, hash) + + resp, err := makeHTTPRequest("POST", url, authToken, []byte(payload)) + handleResponse(resp, err, "POST") +} + +func modifyRequest(hash, id string) { + log.Println("Modifying DNS record:", id, "with hash:", hash) + + zoneID, authToken, domain := os.Getenv("ZONE_ID"), os.Getenv("API_TOKEN"), os.Getenv("DOMAIN") + url := cloudflareAPI + zoneID + "/dns_records/" + id + + payload := fmt.Sprintf( + `{"type":"TLSA","name":"_%d._%s.%s","data":{"usage":%d,"selector":%d,"matching_type":%d,"certificate":"%s"}}`, + port, protocol, domain, usage, selector, matchingType, hash) + + resp, err := makeHTTPRequest("PUT", url, authToken, []byte(payload)) + handleResponse(resp, err, "PUT") +} + +func makeHTTPRequest(method, url, authToken string, payload []byte) (*http.Response, error) { + req, err := http.NewRequest(method, url, bytes.NewBuffer(payload)) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "Bearer "+authToken) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + return client.Do(req) +} + +func handleResponse(resp *http.Response, err error, action string) { + if err != nil { + log.Println("Error:", err) + os.Exit(1) + } + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + log.Println("Error closing HTTP body", err) + } + }(resp.Body) + + log.Println(action, "HTTP Status Code:", resp.Status) + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Println("Error reading response body:", err) + } else { + log.Println("Response Body:", string(body)) + } + os.Exit(1) + } +} + +func checkData(record tlsaRecord, hash string) (correct bool) { + return record.Data.Usage == usage && + record.Data.Selector == selector && + record.Data.MatchingType == matchingType && + record.Data.Certificate == hash +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0454d99 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/nixigaj/cf-tlsa-acmesh + +go 1.16