From 214e3fabd498198bfbdbc2dfdc1ee2bef4ad95f8 Mon Sep 17 00:00:00 2001 From: rskntroot Date: Mon, 29 Jul 2024 09:35:50 +0000 Subject: [PATCH] + feat ipv6; + systemd integ --- Cargo.lock | 323 ++++++++++++++++++++++++- Cargo.toml | 7 +- README.md | 106 ++++---- aws.policy | 24 ++ AllowRoute53RecordUpdate.policy => new | 2 +- r53-ddns.service | 16 ++ src/dns.rs | 52 ++++ src/main.rs | 168 ++----------- src/route53.rs | 163 +++++++++++++ 9 files changed, 650 insertions(+), 211 deletions(-) create mode 100644 aws.policy rename AllowRoute53RecordUpdate.policy => new (89%) create mode 100644 r53-ddns.service create mode 100644 src/dns.rs create mode 100644 src/route53.rs diff --git a/Cargo.lock b/Cargo.lock index 156ccca..a7601d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index de24903..2cc9eae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" \ No newline at end of file diff --git a/README.md b/README.md index f0c2447..b7122ee 100644 --- a/README.md +++ b/README.md @@ -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= 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 --domain-name +Usage: r53-ddns --dns-zone-id --domain-name Options: - -z, --zone-id DNS ZONE ID (see AWS Console Route53) + -z, --dns-zone-id DNS ZONE ID (see AWS Console Route53) -d, --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. diff --git a/aws.policy b/aws.policy new file mode 100644 index 0000000..18fcfaa --- /dev/null +++ b/aws.policy @@ -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": "*" + } + ] +} \ No newline at end of file diff --git a/AllowRoute53RecordUpdate.policy b/new similarity index 89% rename from AllowRoute53RecordUpdate.policy rename to new index 951311c..fbde716 100644 --- a/AllowRoute53RecordUpdate.policy +++ b/new @@ -10,7 +10,7 @@ "route53:GetChange" ], "Resource": [ - "arn:aws:route53:::hostedzone/${zone_id}", + "arn:aws:route53:::hostedzone/", "arn:aws:route53:::change/*" ] }, diff --git a/r53-ddns.service b/r53-ddns.service new file mode 100644 index 0000000..cf95872 --- /dev/null +++ b/r53-ddns.service @@ -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 \ No newline at end of file diff --git a/src/dns.rs b/src/dns.rs new file mode 100644 index 0000000..e5840fa --- /dev/null +++ b/src/dns.rs @@ -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> { + 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 = 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 + ); + } +} diff --git a/src/main.rs b/src/main.rs index f39397b..ba04cdd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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> { 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 = 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::() - .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> { - Ok(reqwest::get("http://ipv4.icanhazip.com") +async fn get_public_ip() -> Result> { + Ok(get("http://icanhazip.com") .await? .text() .await? .trim() .to_string() - .parse::()?) + .parse::()?) } #[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()); } } diff --git a/src/route53.rs b/src/route53.rs new file mode 100644 index 0000000..c3217f5 --- /dev/null +++ b/src/route53.rs @@ -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> { + 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 = + 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> { + // 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, Box> { + // 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 = 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> { + 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"), + } + } +}