-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit 82fa656
Showing
5 changed files
with
386 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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). |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
module github.com/nixigaj/cf-tlsa-acmesh | ||
|
||
go 1.16 |