Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
nixigaj committed Dec 10, 2023
0 parents commit 82fa656
Show file tree
Hide file tree
Showing 5 changed files with 386 additions and 0 deletions.
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Editors
.idea/
.vscode/

# Executables
cf-tlsa-acmesh
cf-tlsa-acmesh.exe
cf-tlsa-acmesh-x86-64
cf-tlsa-acmesh-arm64
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -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.
90 changes: 90 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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=<ZONE_ID>
export API_TOKEN=<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=<TOKEN> \
CF_Account_ID=<ACCOUNT_ID> \
CF_Zone_ID=<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).
263 changes: 263 additions & 0 deletions cf-tlsa-acmesh.go
Original file line number Diff line number Diff line change
@@ -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
}
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/nixigaj/cf-tlsa-acmesh

go 1.16

0 comments on commit 82fa656

Please sign in to comment.