+ feat ipv6; + systemd integ

This commit is contained in:
rskntroot
2024-07-29 09:35:50 +00:00
parent bf97e5a498
commit 214e3fabd4
9 changed files with 650 additions and 211 deletions

323
Cargo.lock generated
View File

@@ -17,6 +17,15 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
[[package]]
name = "aho-corasick"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
dependencies = [
"memchr",
]
[[package]]
name = "anstream"
version = "0.6.15"
@@ -66,6 +75,17 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "async-trait"
version = "0.1.81"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e0c28dcc82d7c8ead5cb13beb15405b57b8546e93215673ff8ca0349a028107"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
@@ -519,7 +539,7 @@ version = "4.5.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d029b67f89d30bbb547c89fd5161293c0aec155fc691d7924b64550662db93e"
dependencies = [
"heck",
"heck 0.5.0",
"proc-macro2",
"quote",
"syn",
@@ -572,6 +592,12 @@ dependencies = [
"typenum",
]
[[package]]
name = "data-encoding"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8566979429cf69b49a5c740c60791108e86440e8be149bbea4fe54d2c32d6e2"
[[package]]
name = "deranged"
version = "0.3.11"
@@ -607,6 +633,41 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "enum-as-inner"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ffccbb6966c05b32ef8fbac435df276c4ae4d3dc55a8cd0eb9745e6c12f546a"
dependencies = [
"heck 0.4.1",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "env_filter"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f2c92ceda6ceec50f43169f9ee8424fe2db276791afde7b2cd8bc084cb376ab"
dependencies = [
"log",
"regex",
]
[[package]]
name = "env_logger"
version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13fa619b91fb2381732789fc5de83b45675e882f66623b7d8cb4f643017018d"
dependencies = [
"anstream",
"anstyle",
"env_filter",
"humantime",
"log",
]
[[package]]
name = "equivalent"
version = "1.0.1"
@@ -780,6 +841,12 @@ version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
name = "heck"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
[[package]]
name = "heck"
version = "0.5.0"
@@ -807,6 +874,17 @@ dependencies = [
"digest",
]
[[package]]
name = "hostname"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c731c3e10504cc8ed35cfe2f1db4c9274c3d35fa486e3b31df46f068ef3e867"
dependencies = [
"libc",
"match_cfg",
"winapi",
]
[[package]]
name = "http"
version = "0.2.12"
@@ -875,6 +953,12 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "humantime"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
[[package]]
name = "hyper"
version = "0.14.30"
@@ -988,6 +1072,16 @@ dependencies = [
"tracing",
]
[[package]]
name = "idna"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c"
dependencies = [
"unicode-bidi",
"unicode-normalization",
]
[[package]]
name = "idna"
version = "0.5.0"
@@ -1008,6 +1102,18 @@ dependencies = [
"hashbrown",
]
[[package]]
name = "ipconfig"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f"
dependencies = [
"socket2",
"widestring",
"windows-sys 0.48.0",
"winreg 0.50.0",
]
[[package]]
name = "ipnet"
version = "2.9.0"
@@ -1041,6 +1147,12 @@ version = "0.2.155"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c"
[[package]]
name = "linked-hash-map"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
[[package]]
name = "linux-raw-sys"
version = "0.4.14"
@@ -1063,6 +1175,21 @@ version = "0.4.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
[[package]]
name = "lru-cache"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c"
dependencies = [
"linked-hash-map",
]
[[package]]
name = "match_cfg"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4"
[[package]]
name = "memchr"
version = "2.7.4"
@@ -1275,6 +1402,12 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de"
[[package]]
name = "proc-macro2"
version = "1.0.86"
@@ -1284,6 +1417,12 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "quick-error"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]]
name = "quote"
version = "1.0.36"
@@ -1300,8 +1439,43 @@ dependencies = [
"aws-config",
"aws-sdk-route53",
"clap",
"env_logger",
"log",
"reqwest",
"thiserror",
"tokio",
"trust-dns-proto",
"trust-dns-resolver",
]
[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
]
[[package]]
@@ -1313,12 +1487,41 @@ dependencies = [
"bitflags 2.6.0",
]
[[package]]
name = "regex"
version = "1.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b91213439dad192326a0d7c6ee3955910425f441d7038e0d6933b0aec5c4517f"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-lite"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a"
[[package]]
name = "regex-syntax"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b"
[[package]]
name = "reqwest"
version = "0.12.5"
@@ -1360,7 +1563,17 @@ dependencies = [
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"winreg",
"winreg 0.52.0",
]
[[package]]
name = "resolv-conf"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52e44394d2086d010551b14b53b1f24e31647570cd1deb0379e2c21b329aba00"
dependencies = [
"hostname",
"quick-error",
]
[[package]]
@@ -1705,6 +1918,26 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "thiserror"
version = "1.0.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "time"
version = "0.3.36"
@@ -1881,6 +2114,52 @@ dependencies = [
"once_cell",
]
[[package]]
name = "trust-dns-proto"
version = "0.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3119112651c157f4488931a01e586aa459736e9d6046d3bd9105ffb69352d374"
dependencies = [
"async-trait",
"cfg-if",
"data-encoding",
"enum-as-inner",
"futures-channel",
"futures-io",
"futures-util",
"idna 0.4.0",
"ipnet",
"once_cell",
"rand",
"smallvec",
"thiserror",
"tinyvec",
"tokio",
"tracing",
"url",
]
[[package]]
name = "trust-dns-resolver"
version = "0.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a3e6c3aff1718b3c73e395d1f35202ba2ffa847c6a62eea0db8fb4cfe30be6"
dependencies = [
"cfg-if",
"futures-util",
"ipconfig",
"lru-cache",
"once_cell",
"parking_lot",
"rand",
"resolv-conf",
"smallvec",
"thiserror",
"tokio",
"tracing",
"trust-dns-proto",
]
[[package]]
name = "try-lock"
version = "0.2.5"
@@ -1927,7 +2206,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c"
dependencies = [
"form_urlencoded",
"idna",
"idna 0.5.0",
"percent-encoding",
]
@@ -2058,6 +2337,34 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "widestring"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7219d36b6eac893fa81e84ebe06485e7dcbb616177469b142df14f1f4deb1311"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-sys"
version = "0.48.0"
@@ -2197,6 +2504,16 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winreg"
version = "0.50.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1"
dependencies = [
"cfg-if",
"windows-sys 0.48.0",
]
[[package]]
name = "winreg"
version = "0.52.0"

View File

@@ -6,6 +6,11 @@ edition = "2021"
[dependencies]
aws-config = { version = "1.1.7", features = ["behavior-version-latest"] }
aws-sdk-route53 = "1.37.0"
tokio = { version = "1", features = ["full"] }
clap = { version = "4.5.11", features = ["derive"] }
env_logger = "0.11"
log = "0.4"
reqwest = { version = "0.12.5", features = ["blocking", "json"] }
thiserror = "1.0"
tokio = { version = "1", features = ["full"] }
trust-dns-proto = "0.23.2"
trust-dns-resolver = "0.23.2"

106
README.md
View File

@@ -1,108 +1,88 @@
# r53-ddns
Route53 Dynamic DNS
## Brief
this was intended to solve the problem of when your local ISP force renews your PublicIP and no one can reach your not-sure-if-toaster-or-minecraft-server.
Submits a Route53 `ChangeRequest` for updating `A` or `AAAA` records when PublicIP drift is detected.
what it ended up as, was instead a mockery of dynamic dns, progam/scripting, error handling, all in memory-safe rust. whatever that means, right?
Drift detection is determined by comparing http request to `icanhazip.com` and a DNS lookup to `cloudflare`.
> "this is the worst example of working code ive ever seen"
> // "ah, but you have seen the code working"
>
> -- Rskntroot 2024
This is intended to be installed on a public-facing loadbalancer.
## Assumptions
1. Your ISP randomly changes your PublicIP and that pisses you off.
1. You have no idea how DDNS is actually supposed to work.
- dns is all smoke and mirrors, confirmed.
1. You just want something that will curl `ipv4.icanhazip.com` and push the update to Route53.
1. You plan on handjamming this into a cron job on your webserver/loadbalancer.
1. ...
1. Profit.
1. You just want something that will curl `ipv4.icanhazip.com`, check 3rd-party dns, and update Route53.
1. Your Name records only contain a single IP. (future update maybe).
Congratulations, this is the package for you.
If so, this is for you.
## Setup
1. use output of below command to create the IAM policy
``` zsh
zone_id=<zone_id> envsubst < AllowRoute53RecordUpdate.policy
1. setup `Route53AllowRecordUpdate.policy`
```zsh
DNS_ZONE_ID=YOURZONEIDHERE \
envsubst < aws.policy > Route53AllowRecordUpdate.policy
```
1. create IAM user, generate access keys for automated service
1. log into aws with the acct on the machine where you install this binary
```
aws sso login --profile
```
1. setup a user-level cron job to poll at your leisure
1. setup link in `/usr/bin`
``` zsh
ln -s -f ~/r53-ddns/target/release/r53-ddns /usr/bin/r53-ddns
```
0 * * * * ~/home/lost/.r53-update-dns.sh
1. setup systemd service and then install as normal
```zsh
DNS_ZONE_ID=YOURZONEIDHERE \
DOMAIN_NAME=your.domain.com. \
envsubst < r53-ddns.service | sudo /etc/system/
```
## Usage
## CLI Usage
```
$ r53-ddns -h
A CLI tool for correcting drift between your PublicIP and Route53 DNS A RECORD
Usage: r53-ddns --zone-id <ZONE_ID> --domain-name <DOMAIN_NAME>
Usage: r53-ddns --dns-zone-id <DNS_ZONE_ID> --domain-name <DOMAIN_NAME>
Options:
-z, --zone-id <ZONE_ID> DNS ZONE ID (see AWS Console Route53)
-z, --dns-zone-id <DNS_ZONE_ID> DNS ZONE ID (see AWS Console Route53)
-d, --domain-name <DOMAIN_NAME> DOMAIN NAME (ex. 'docs.rskio.com.')
-h, --help Print help
```
### Drift Detected
``` zsh
$ r53-ddns -z ${aws_dns_zone_id} -d smp.rskio.com.
```
### Example
```
The dynamic IP provided by the ISP has drifted. 10.0.11.201 -> 10.0.88.219
Requested DNS record update to PublicIP: 10.0.88.219
Change ID: /change/C04224022UE1ZQA26RE7O, Status: Pending
Change ID: /change/C04224022UE1ZQA26RE7O, Status: Insync
The change request has been completed.
```
$ systemctl status r53-ddns.service
● r53-ddns.service - Route53 Dynamic DNS Service
Loaded: loaded (/etc/systemd/system/r53-ddns.service; enabled; vendor preset: enabled)
Active: active (running) since Mon 2024-07-29 09:03:40 UTC; 7min ago
Main PID: 215630 (r53-ddns)
Tasks: 6 (limit: 18886)
Memory: 3.6M
CPU: 389ms
CGroup: /system.slice/r53-ddns.service
└─215630 /usr/bin/r53-ddns -z [##TRUNCATED##] -d rskio.com.
### No Drift Detected
``` zsh
$ r53-ddns -z ${aws_dns_zone_id} -d example.com.
```
```
The DNS record is currently up to date with the public IP: 10.0.88.219
```
### Tests
Yeah, I have em! Well... one of them.
```
$ cargo test
Compiling r53-ddns v0.1.0 (~/workspace/r53-ddns)
Finished `test` profile [unoptimized + debuginfo] target(s) in 3.81s
Running unittests src/main.rs (target/debug/deps/r53_ddns-9ff92b89721daeea)
running 1 test
test tests::get_public_ip_works ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.07s
Jul 29 09:03:40 hostname systemd[1]: Started Route53 Dynamic DNS Service.
Jul 29 09:03:40 hostname r53-ddns[215630]: [2024-07-29T09:03:40Z INFO r53_ddns] starting with options: -z [##TRUNCATED##] -d rskio.com.
Jul 29 09:09:41 hostname r53-ddns[215630]: [2024-07-29T09:09:41Z INFO r53_ddns::dns] dynamic ip drift detected: 10.0.0.1 -> 71.211.88.219
Jul 29 09:09:41 hostname r53-ddns[215630]: [2024-07-29T09:09:41Z INFO r53_ddns::route53] requesting update to route53 record for A rskio.com. -> 71.211.88.219
Jul 29 09:09:41 hostname r53-ddns[215630]: [2024-07-29T09:09:41Z INFO r53_ddns::route53] change_id: /change/C02168177BNS6R50C32Q has status: Pending
Jul 29 09:10:41 hostname r53-ddns[215630]: [2024-07-29T09:09:41Z INFO r53_ddns::route53] change_id: /change/C02168177BNS6R50C32Q has status: Insync
```
## Q&A
> Why are you doing AWS calls instead of us nslookup and compare that?
> Why did you do create this monster in rust?
Why in the world would internal DNS give me a PublicIP? Imagine not implementing internal DNS.
> Why did you do create this monster?
To prove to myself that with the help of LLMs that even I could go from nothing to a deployed tokio async rust binary in less than 8 hours. And thats exactly what I did.
To be able to handle errors in the future.
> wen IPv6?
Stfu, John. How about, wen PR?
It should work with IPv6.

24
aws.policy Normal file
View File

@@ -0,0 +1,24 @@
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "VisualEditor0",
"Effect": "Allow",
"Action": [
"route53:ListResourceRecordSets",
"route53:ChangeResourceRecordSets",
"route53:GetChange"
],
"Resource": [
"arn:aws:route53:::hostedzone/${DNS_ZONE_ID}",
"arn:aws:route53:::change/*"
]
},
{
"Sid": "VisualEditor1",
"Effect": "Allow",
"Action": "route53:TestDNSAnswer",
"Resource": "*"
}
]
}

View File

@@ -10,7 +10,7 @@
"route53:GetChange"
],
"Resource": [
"arn:aws:route53:::hostedzone/${zone_id}",
"arn:aws:route53:::hostedzone/",
"arn:aws:route53:::change/*"
]
},

16
r53-ddns.service Normal file
View File

@@ -0,0 +1,16 @@
[Unit]
Description=Route53 Dynamic DNS Service
After=network-online.target
Requires=network-online.target
[Service]
Type=simple
RemainAfterExit=yes
ExecStart=/usr/bin/r53-ddns -z ${DNS_ZONE_ID} -d ${DOMAIN_NAME}
User=${USER}
Restart=always
RestartSec=60
[Install]
WantedBy=multi-user.target

52
src/dns.rs Normal file
View File

@@ -0,0 +1,52 @@
use std::error::Error;
use std::net::IpAddr;
use log::info;
use trust_dns_proto::rr::record_type::RecordType;
use trust_dns_proto::rr::RecordData;
use trust_dns_resolver::config::{ResolverConfig, ResolverOpts};
use trust_dns_resolver::TokioAsyncResolver;
pub async fn is_addr_current(domain: &str, ip_addr: IpAddr) -> Result<bool, Box<dyn Error>> {
let response = TokioAsyncResolver::tokio(ResolverConfig::cloudflare(), ResolverOpts::default())
.lookup(
domain,
match ip_addr {
IpAddr::V4(_) => RecordType::A,
IpAddr::V6(_) => RecordType::AAAA,
},
)
.await?;
let mut record_ip: Option<IpAddr> = None;
for record in response.into_iter() {
record_ip = record.into_rdata().ip_addr();
if !record_ip.is_none() && record_ip == Some(ip_addr) {
return Ok(true);
}
}
info!(
"dynamic ip drift detected: {} -> {}",
record_ip.unwrap(),
ip_addr
);
Ok(false)
}
#[cfg(test)]
mod unit {
use std::net::IpAddr;
use std::str::FromStr;
#[tokio::test]
async fn test_is_addr_current() {
let domain = "rskio.com";
let ip_addr = IpAddr::from_str("0.0.0.0").unwrap();
assert_eq!(
super::is_addr_current(domain, ip_addr).await.unwrap(),
false
);
}
}

View File

@@ -1,12 +1,12 @@
use aws_config::meta::region::RegionProviderChain;
use aws_sdk_route53 as route53;
use aws_sdk_route53::types::{
Change, ChangeAction, ChangeBatch, ResourceRecord, ResourceRecordSet,
};
mod dns;
mod route53;
use clap::Parser;
use std::net::Ipv4Addr;
use env_logger::Builder;
use log::info;
use reqwest::get;
use std::net::IpAddr;
use tokio::time::{sleep, Duration};
#[derive(Parser)]
#[clap(
@@ -14,166 +14,48 @@ use std::net::Ipv4Addr;
about = "A CLI tool for correcting drift between your PublicIP and Route53 DNS A RECORD"
)]
struct Args {
#[clap(short, long, help = "DNS ZONE ID\t(see AWS Console Route53)")]
zone_id: String,
#[clap(short = 'z', long, help = "DNS ZONE ID\t(see AWS Console Route53)")]
dns_zone_id: String,
#[clap(short, long, help = "DOMAIN NAME\t(ex. 'docs.rskio.com.')")]
domain_name: String,
}
const RECORD_TYPE: &'static str = "A";
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = Args::parse();
Builder::new().filter(None, log::LevelFilter::Info).init();
// get aws r53 client
let region_provider = RegionProviderChain::default_provider();
let config = aws_config::from_env().region(region_provider).load().await;
let client = route53::Client::new(&config);
info!(
"starting with options: -z {} -d {}",
&args.dns_zone_id, &args.domain_name,
);
// get a list of resource_record_sets
let list_resource_record_sets = client
.list_resource_record_sets()
.hosted_zone_id(&args.zone_id)
.start_record_name(&args.domain_name)
.send()
.await?;
// match a single resource record_set
let mut resource_record_set: Option<ResourceRecordSet> = None;
for rrs in list_resource_record_sets.resource_record_sets {
if rrs.name.as_str() == &args.domain_name && rrs.r#type.as_str() == "A" {
resource_record_set = Some(rrs);
break;
}
}
// if record is none: exit
// else shadow resource_record_set with a safe unwrap
let resource_record_set = match resource_record_set.is_none() {
true => {
println!(
"No ResourceRecordSet found in Zone: {} for Record like: {} {}",
&args.zone_id, RECORD_TYPE, &args.domain_name,
);
std::process::exit(1);
}
false => resource_record_set.unwrap(),
};
// if record contains empty resource_records, exit
// else shadow resource_records with a safe unwrap
let resource_records = match resource_record_set.resource_records.is_none() {
true => {
println!(
"No ResourceRecord found Zone: {} for Record like: {} {}",
&args.zone_id, RECORD_TYPE, &args.domain_name,
);
std::process::exit(1);
}
false => &resource_record_set.resource_records.unwrap(),
};
// get the first ip in the DNS record
let record_ip = resource_records[0]
.value
.parse::<Ipv4Addr>()
.expect("Failed to parse IP address");
let public_ip: Ipv4Addr = get_public_ip().await?;
// if no drift detected, exit
if record_ip == public_ip {
println!(
"The DNS record is currently up to date with the public IP: {}",
record_ip
);
return Ok(());
}
let msg: String = format!("The dynamic IP provided by the ISP has drifted.",);
println!("{} {} -> {}", msg, record_ip, public_ip);
// prepare aws r53 change request
let change = Change::builder()
.action(ChangeAction::Upsert)
.resource_record_set(
ResourceRecordSet::builder()
.name(resource_record_set.name.clone())
.r#type(resource_record_set.r#type.clone())
.ttl(resource_record_set.ttl.unwrap())
.resource_records(
ResourceRecord::builder()
.set_value(Some(public_ip.to_string()))
.build()
.unwrap(),
)
.build()
.unwrap(),
) // Build the ResourceRecordSet
.build()
.unwrap(); // Build the Change
// Change the resource record set
let response = client
.change_resource_record_sets()
.hosted_zone_id(&args.zone_id)
.change_batch(
ChangeBatch::builder()
.set_changes(Some(vec![change]))
.set_comment(Some(msg))
.build()
.unwrap(),
)
.send()
.await?;
println!("Requested DNS record update to PublicIP: {}", public_ip);
// Get the change ID from the response
let change_id = response.change_info.unwrap().id;
// Check the status of the change request every 60 seconds
loop {
let change_response = client.get_change().id(&change_id).send().await?;
let public_ip = get_public_ip().await?;
// check the status
if let Some(change_info) = change_response.change_info {
println!("Change ID: {}, Status: {:?}", change_id, change_info.status);
if !dns::is_addr_current(&args.domain_name, public_ip).await? {
route53::update_record(&args.dns_zone_id, &args.domain_name, public_ip).await?;
};
// break loop if the change is insync
if change_info.status == route53::types::ChangeStatus::Insync {
println!("The change request has been completed.");
break;
}
}
// sleep for 60 seconds before checking again...
tokio::time::sleep(tokio::time::Duration::from_secs(60)).await;
sleep(Duration::from_secs(60)).await;
}
Ok(())
}
async fn get_public_ip() -> Result<Ipv4Addr, Box<dyn std::error::Error>> {
Ok(reqwest::get("http://ipv4.icanhazip.com")
async fn get_public_ip() -> Result<IpAddr, Box<dyn std::error::Error>> {
Ok(get("http://icanhazip.com")
.await?
.text()
.await?
.trim()
.to_string()
.parse::<Ipv4Addr>()?)
.parse::<IpAddr>()?)
}
#[cfg(test)]
mod tests {
use super::get_public_ip;
mod unit {
#[tokio::test]
async fn get_public_ip_works() {
dbg!(get_public_ip().await.unwrap());
async fn test_get_public_ip() {
dbg!(super::get_public_ip().await.unwrap());
}
}

163
src/route53.rs Normal file
View File

@@ -0,0 +1,163 @@
use std::error::Error;
use std::fmt;
use std::net::IpAddr;
use aws_config::meta::region::RegionProviderChain;
use aws_sdk_route53 as r53;
use aws_sdk_route53::types::{
Change, ChangeAction, ChangeBatch, ResourceRecord, ResourceRecordSet,
};
use log::info;
use thiserror::Error;
use tokio::time::{sleep, Duration};
pub async fn update_record(
dns_zone_id: &str,
domain_name: &str,
public_ip: IpAddr,
) -> Result<(), Box<dyn Error>> {
let record_type: RecordType = match public_ip {
IpAddr::V4(_) => RecordType::A,
IpAddr::V6(_) => RecordType::AAAA,
};
let client: r53::Client = get_client().await?;
let resource_record_set: Option<ResourceRecordSet> =
get_single_record_set(&client, &dns_zone_id, &domain_name, &record_type).await?;
match resource_record_set.is_none() {
true => return Err(Box::new(Route53UpdateError::NoRecordAvailable)),
false => {
info!(
"requesting update to route53 record for {} {} -> {}",
record_type, domain_name, public_ip
);
return Ok(submit_single_change_request(
&client,
resource_record_set.unwrap(),
&public_ip,
&dns_zone_id,
)
.await?);
}
}
}
pub async fn get_client() -> Result<aws_sdk_route53::Client, Box<dyn Error>> {
// get aws r53 client
Ok(r53::Client::new(
&aws_config::from_env()
.region(RegionProviderChain::default_provider())
.load()
.await,
))
}
pub async fn get_single_record_set(
client: &r53::Client,
dns_zone_id: &str,
domain_name: &str,
record_type: &RecordType,
) -> Result<Option<ResourceRecordSet>, Box<dyn Error>> {
// get a list of resource_record_sets
let list_resource_record_sets = client
.list_resource_record_sets()
.hosted_zone_id(dns_zone_id.to_string())
.start_record_name(domain_name.to_string())
.send()
.await?;
// match a single resource record_set
let mut resource_record_set: Option<ResourceRecordSet> = None;
for rrs in list_resource_record_sets.resource_record_sets {
if rrs.name.as_str() == domain_name && rrs.r#type.as_str() == record_type.to_string() {
resource_record_set = Some(rrs);
break;
}
}
Ok(resource_record_set)
}
pub async fn submit_single_change_request(
client: &r53::Client,
resource_record_set: ResourceRecordSet,
public_ip: &IpAddr,
dns_zone_id: &str,
) -> Result<(), Box<dyn Error>> {
let change = Change::builder()
.action(ChangeAction::Upsert)
.resource_record_set(
ResourceRecordSet::builder()
.name(resource_record_set.name.clone())
.r#type(resource_record_set.r#type.clone())
.ttl(resource_record_set.ttl.clone().unwrap())
.resource_records(
ResourceRecord::builder()
.set_value(Some(public_ip.to_string()))
.build()
.unwrap(),
)
.build()
.unwrap(),
)
.build()
.unwrap();
let msg: String = format!("ISP provided dynamic IP has drifted.");
// submit batch change request of the resource record set
let response = client
.change_resource_record_sets()
.hosted_zone_id(dns_zone_id)
.change_batch(
ChangeBatch::builder()
.set_changes(Some(vec![change]))
.set_comment(Some(msg))
.build()
.unwrap(),
)
.send()
.await?;
let change_id = response.change_info.unwrap().id;
// check change request status every 60 seconds
loop {
let change_response = client.get_change().id(&change_id).send().await?;
if let Some(change_info) = change_response.change_info {
info!(
"change_id: {} has status: {:?}",
change_id, change_info.status
);
// break loop if the change is insync
if change_info.status == r53::types::ChangeStatus::Insync {
return Ok(());
}
}
sleep(Duration::from_secs(180)).await;
}
}
#[derive(Error, Debug)]
pub enum Route53UpdateError {
#[error("zone does not contain requested name record")]
NoRecordAvailable,
}
#[derive(Debug, PartialEq)]
pub enum RecordType {
A,
AAAA,
}
impl fmt::Display for RecordType {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
RecordType::A => write!(f, "A"),
RecordType::AAAA => write!(f, "AAAA"),
}
}
}