First version of D53 that does something
First working version
This commit is contained in:
commit
ed2653ae7d
13 changed files with 3790 additions and 0 deletions
40
.drone.yml
Normal file
40
.drone.yml
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
---
|
||||||
|
kind: pipeline
|
||||||
|
name: default
|
||||||
|
|
||||||
|
node:
|
||||||
|
nix-daemon: 1
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: check formatting
|
||||||
|
image: nixpkgs/nix:nixos-22.05
|
||||||
|
environment:
|
||||||
|
NIX_PATH: 'nixpkgs=channel:nixos-22.05'
|
||||||
|
commands:
|
||||||
|
- nix-shell -p cargo -p rustfmt --run 'cargo fmt -- --check'
|
||||||
|
|
||||||
|
- name: build
|
||||||
|
image: nixpkgs/nix:nixos-22.05
|
||||||
|
commands:
|
||||||
|
- nix build --extra-experimental-features nix-command --extra-experimental-features flakes .#debug.x86_64-linux.d53
|
||||||
|
|
||||||
|
- name: test
|
||||||
|
image: nixpkgs/nix:nixos-22.05
|
||||||
|
commands:
|
||||||
|
- nix build --extra-experimental-features nix-command --extra-experimental-features flakes .#test.x86_64-linux.d53
|
||||||
|
- ./result-bin/bin/d53-*
|
||||||
|
|
||||||
|
trigger:
|
||||||
|
event:
|
||||||
|
- custom
|
||||||
|
- push
|
||||||
|
- pull_request
|
||||||
|
- tag
|
||||||
|
- cron
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
kind: signature
|
||||||
|
hmac: 49cde53ec25364cc3b3f041092c8e658fe9252342253757d86814ca12d5cb0f7
|
||||||
|
|
||||||
|
...
|
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
/target
|
||||||
|
result
|
||||||
|
result-bin
|
1178
Cargo.lock
generated
Normal file
1178
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
19
Cargo.toml
Normal file
19
Cargo.toml
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
[package]
|
||||||
|
name = "d53"
|
||||||
|
description = "D53 is a dynamic DNS updater that sources information from Consul to route services to the correct place"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
async-trait = "0.1"
|
||||||
|
anyhow = "1.0.66"
|
||||||
|
futures = "0.3"
|
||||||
|
log = "0.4"
|
||||||
|
pretty_env_logger = "0.4"
|
||||||
|
df-consul = "0.1.0"
|
||||||
|
structopt = "0.3"
|
||||||
|
tokio = { version = "1.22", default-features = false, features = ["rt", "rt-multi-thread", "io-util", "net", "time", "macros", "sync", "signal", "fs"] }
|
||||||
|
reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls-webpki-roots" ] }
|
||||||
|
serde = { version = "1.0.107", features = ["derive"] }
|
108
flake.lock
Normal file
108
flake.lock
Normal file
|
@ -0,0 +1,108 @@
|
||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"cargo2nix": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-compat": "flake-compat",
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
],
|
||||||
|
"rust-overlay": "rust-overlay"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1666087781,
|
||||||
|
"narHash": "sha256-trKVdjMZ8mNkGfLcY5LsJJGtdV3xJDZnMVrkFjErlcs=",
|
||||||
|
"owner": "Alexis211",
|
||||||
|
"repo": "cargo2nix",
|
||||||
|
"rev": "a7a61179b66054904ef6a195d8da736eaaa06c36",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "Alexis211",
|
||||||
|
"repo": "cargo2nix",
|
||||||
|
"rev": "a7a61179b66054904ef6a195d8da736eaaa06c36",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-compat": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1650374568,
|
||||||
|
"narHash": "sha256-Z+s0J8/r907g149rllvwhb4pKi8Wam5ij0st8PwAh+E=",
|
||||||
|
"owner": "edolstra",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"rev": "b4a34015c698c7793d592d66adbab377907a2be8",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "edolstra",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-utils": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1659877975,
|
||||||
|
"narHash": "sha256-zllb8aq3YO3h8B/U0/J1WBgAL8EX5yWf5pMj3G0NAmc=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "c0e246b9b83f637f4681389ecabcb2681b4f3af0",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1665657542,
|
||||||
|
"narHash": "sha256-mojxNyzbvmp8NtVtxqiHGhRfjCALLfk9i/Uup68Y5q8=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "a3073c49bc0163fea6a121c276f526837672b555",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "a3073c49bc0163fea6a121c276f526837672b555",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"cargo2nix": "cargo2nix",
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rust-overlay": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": [
|
||||||
|
"cargo2nix",
|
||||||
|
"flake-utils"
|
||||||
|
],
|
||||||
|
"nixpkgs": [
|
||||||
|
"cargo2nix",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1664247556,
|
||||||
|
"narHash": "sha256-J4vazHU3609ekn7dr+3wfqPo5WGlZVAgV7jfux352L0=",
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"rev": "524db9c9ea7bc7743bb74cdd45b6d46ea3fcc2ab",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
36
flake.nix
Normal file
36
flake.nix
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
{
|
||||||
|
description = "D53 is a dynamic DNS updater that sources information from Consul to route services to the correct place";
|
||||||
|
|
||||||
|
inputs.nixpkgs.url = "github:NixOS/nixpkgs/a3073c49bc0163fea6a121c276f526837672b555";
|
||||||
|
inputs.cargo2nix = {
|
||||||
|
# As of 2022-10-18: two small patches over unstable branch, one for clippy and one to fix feature detection
|
||||||
|
url = "github:Alexis211/cargo2nix/a7a61179b66054904ef6a195d8da736eaaa06c36";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs = { self, nixpkgs, cargo2nix }:
|
||||||
|
let
|
||||||
|
pkgs = import nixpkgs {
|
||||||
|
system = "x86_64-linux";
|
||||||
|
overlays = [ cargo2nix.overlays.default ];
|
||||||
|
};
|
||||||
|
packageFun = import ./Cargo.nix;
|
||||||
|
rustVersion = "1.63.0";
|
||||||
|
|
||||||
|
compile = args: compileMode:
|
||||||
|
let
|
||||||
|
packageSet = pkgs.rustBuilder.makePackageSet ({
|
||||||
|
inherit packageFun rustVersion;
|
||||||
|
} // args);
|
||||||
|
in
|
||||||
|
packageSet.workspace.d53 {
|
||||||
|
inherit compileMode;
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
test.x86_64-linux.d53 = compile { release = false; } "test";
|
||||||
|
debug.x86_64-linux.d53 = compile { release = false; } "build";
|
||||||
|
packages.x86_64-linux.d53 = compile { release = true; } "build";
|
||||||
|
packages.x86_64-linux.default = self.packages.x86_64-linux.d53;
|
||||||
|
};
|
||||||
|
}
|
8
run_local.sh
Executable file
8
run_local.sh
Executable file
|
@ -0,0 +1,8 @@
|
||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
RUST_LOG=d53=info cargo run \
|
||||||
|
-- \
|
||||||
|
--consul-addr http://localhost:8500 \
|
||||||
|
--provider gandi \
|
||||||
|
--gandi-api-key $GANDI_API_KEY \
|
||||||
|
--allowed-domains staging.deuxfleurs.org
|
281
src/dns_config.rs
Normal file
281
src/dns_config.rs
Normal file
|
@ -0,0 +1,281 @@
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::fmt;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::{cmp, time::Duration};
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use futures::future::BoxFuture;
|
||||||
|
use futures::stream::{FuturesUnordered, StreamExt};
|
||||||
|
|
||||||
|
use log::*;
|
||||||
|
use tokio::{select, sync::watch, time::sleep};
|
||||||
|
|
||||||
|
use df_consul::*;
|
||||||
|
|
||||||
|
const IPV4_TARGET_METADATA_TAG: &str = "public_ipv4";
|
||||||
|
const IPV6_TARGET_METADATA_TAG: &str = "public_ipv6";
|
||||||
|
const CNAME_TARGET_METADATA_TAG: &str = "cname_target";
|
||||||
|
|
||||||
|
// ---- Extract DNS config from Consul catalog ----
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct DnsConfig {
|
||||||
|
pub entries: HashMap<DnsEntryKey, DnsEntryValue>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Hash, PartialEq, Eq)]
|
||||||
|
pub struct DnsEntryKey {
|
||||||
|
pub domain: String,
|
||||||
|
pub subdomain: String,
|
||||||
|
pub record_type: DnsRecordType,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub struct DnsEntryValue {
|
||||||
|
pub targets: HashSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Hash, PartialEq, Eq)]
|
||||||
|
#[allow(clippy::upper_case_acronyms)]
|
||||||
|
pub enum DnsRecordType {
|
||||||
|
A,
|
||||||
|
AAAA,
|
||||||
|
CNAME,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DnsConfig {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
entries: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add(&mut self, k: DnsEntryKey, v: DnsEntryValue) {
|
||||||
|
if let Some(ent) = self.entries.get_mut(&k) {
|
||||||
|
ent.targets.extend(v.targets);
|
||||||
|
} else {
|
||||||
|
self.entries.insert(k, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_d53_tag(tag: &str, node: &ConsulNode) -> Option<(DnsEntryKey, DnsEntryValue)> {
|
||||||
|
let splits = tag.split(' ').collect::<Vec<_>>();
|
||||||
|
if splits.len() != 3 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (record_type, targets) = match splits[0] {
|
||||||
|
"d53-a" => match node.meta.get(IPV4_TARGET_METADATA_TAG) {
|
||||||
|
Some(tgt) => (DnsRecordType::A, [tgt.to_string()].into_iter().collect()),
|
||||||
|
None => {
|
||||||
|
warn!("Got d53-a tag `{}` but node {} does not have a {} metadata value. Tag is ignored.", tag, node.node, IPV4_TARGET_METADATA_TAG);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"d53-aaaa" => match node.meta.get(IPV6_TARGET_METADATA_TAG) {
|
||||||
|
Some(tgt) => (DnsRecordType::AAAA, [tgt.to_string()].into_iter().collect()),
|
||||||
|
None => {
|
||||||
|
warn!("Got d53-aaaa tag `{}` but node {} does not have a {} metadata value. Tag is ignored.", tag, node.node, IPV6_TARGET_METADATA_TAG);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"d53-cname" => match node.meta.get(CNAME_TARGET_METADATA_TAG) {
|
||||||
|
Some(tgt) => (
|
||||||
|
DnsRecordType::CNAME,
|
||||||
|
[tgt.to_string()].into_iter().collect(),
|
||||||
|
),
|
||||||
|
None => {
|
||||||
|
warn!("Got d53-cname tag `{}` but node {} does not have a {} metadata value. Tag is ignored.", tag, node.node, CNAME_TARGET_METADATA_TAG);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Some((
|
||||||
|
DnsEntryKey {
|
||||||
|
domain: splits[1].to_string(),
|
||||||
|
subdomain: splits[2].to_string(),
|
||||||
|
record_type,
|
||||||
|
},
|
||||||
|
DnsEntryValue { targets },
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_consul_catalog(catalog: &ConsulNodeCatalog, dns_config: &mut DnsConfig) {
|
||||||
|
trace!("Parsing node catalog: {:#?}", catalog);
|
||||||
|
|
||||||
|
for (_, svc) in catalog.services.iter() {
|
||||||
|
for tag in svc.tags.iter() {
|
||||||
|
if let Some((k, v)) = parse_d53_tag(tag, &catalog.node) {
|
||||||
|
dns_config.add(k, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct NodeWatchState {
|
||||||
|
last_idx: Option<usize>,
|
||||||
|
last_catalog: Option<ConsulNodeCatalog>,
|
||||||
|
retries: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawn_dns_config_task(
|
||||||
|
consul: Consul,
|
||||||
|
mut must_exit: watch::Receiver<bool>,
|
||||||
|
) -> watch::Receiver<Arc<DnsConfig>> {
|
||||||
|
let (tx, rx) = watch::channel(Arc::new(DnsConfig::new()));
|
||||||
|
|
||||||
|
let consul = Arc::new(consul);
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut nodes = HashMap::new();
|
||||||
|
let mut watches = FuturesUnordered::<BoxFuture<'static, (String, Result<_>)>>::new();
|
||||||
|
|
||||||
|
let mut node_site = HashMap::new();
|
||||||
|
|
||||||
|
while !*must_exit.borrow() {
|
||||||
|
let list_nodes = select! {
|
||||||
|
ln = consul.list_nodes() => ln,
|
||||||
|
_ = must_exit.changed() => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
match list_nodes {
|
||||||
|
Ok(consul_nodes) => {
|
||||||
|
debug!("Watched consul nodes: {:?}", consul_nodes);
|
||||||
|
for consul_node in consul_nodes {
|
||||||
|
let node = &consul_node.node;
|
||||||
|
if !nodes.contains_key(node) {
|
||||||
|
nodes.insert(node.clone(), NodeWatchState::default());
|
||||||
|
|
||||||
|
let node = node.to_string();
|
||||||
|
let consul = consul.clone();
|
||||||
|
|
||||||
|
watches.push(Box::pin(async move {
|
||||||
|
let res = consul.watch_node(&node, None).await;
|
||||||
|
(node, res)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if let Some(site) = consul_node.meta.get("site") {
|
||||||
|
node_site.insert(node.clone(), site.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Could not get Consul node list: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let next_watch = select! {
|
||||||
|
nw = watches.next() => nw,
|
||||||
|
_ = must_exit.changed() => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let (node, res): (String, Result<_>) = match next_watch {
|
||||||
|
Some(v) => v,
|
||||||
|
None => {
|
||||||
|
warn!("No nodes currently watched in dns_config.rs");
|
||||||
|
sleep(Duration::from_secs(10)).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok((catalog, new_idx)) => {
|
||||||
|
let mut watch_state = nodes.get_mut(&node).unwrap();
|
||||||
|
watch_state.last_idx = Some(new_idx);
|
||||||
|
watch_state.last_catalog = Some(catalog);
|
||||||
|
watch_state.retries = 0;
|
||||||
|
|
||||||
|
let idx = watch_state.last_idx;
|
||||||
|
let consul = consul.clone();
|
||||||
|
watches.push(Box::pin(async move {
|
||||||
|
let res = consul.watch_node(&node, idx).await;
|
||||||
|
(node, res)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let mut watch_state = nodes.get_mut(&node).unwrap();
|
||||||
|
watch_state.retries += 1;
|
||||||
|
watch_state.last_idx = None;
|
||||||
|
|
||||||
|
let will_retry_in =
|
||||||
|
retry_to_time(watch_state.retries, Duration::from_secs(600));
|
||||||
|
error!(
|
||||||
|
"Failed to query consul for node {}. Will retry in {}s. {}",
|
||||||
|
node,
|
||||||
|
will_retry_in.as_secs(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
|
||||||
|
let consul = consul.clone();
|
||||||
|
watches.push(Box::pin(async move {
|
||||||
|
sleep(will_retry_in).await;
|
||||||
|
let res = consul.watch_node(&node, None).await;
|
||||||
|
(node, res)
|
||||||
|
}));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut dns_config = DnsConfig::new();
|
||||||
|
for (_, watch_state) in nodes.iter() {
|
||||||
|
if let Some(catalog) = &watch_state.last_catalog {
|
||||||
|
parse_consul_catalog(catalog, &mut dns_config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.send(Arc::new(dns_config)).expect("Internal error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
rx
|
||||||
|
}
|
||||||
|
|
||||||
|
fn retry_to_time(retries: u32, max_time: Duration) -> Duration {
|
||||||
|
// 1.2^x seems to be a good value to exponentially increase time at a good pace
|
||||||
|
// eg. 1.2^32 = 341 seconds ~= 5 minutes - ie. after 32 retries we wait 5
|
||||||
|
// minutes
|
||||||
|
Duration::from_secs(cmp::min(
|
||||||
|
max_time.as_secs(),
|
||||||
|
1.2f64.powf(retries as f64) as u64,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Display impls ----
|
||||||
|
|
||||||
|
impl std::fmt::Display for DnsRecordType {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
DnsRecordType::A => write!(f, "A"),
|
||||||
|
DnsRecordType::AAAA => write!(f, "AAAA"),
|
||||||
|
DnsRecordType::CNAME => write!(f, "CNAME"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for DnsEntryKey {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"{}.{} IN {}",
|
||||||
|
self.subdomain, self.domain, self.record_type
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for DnsEntryValue {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "[")?;
|
||||||
|
for (i, tgt) in self.targets.iter().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
write!(f, " ")?;
|
||||||
|
}
|
||||||
|
write!(f, "{}", tgt)?;
|
||||||
|
}
|
||||||
|
write!(f, "]")
|
||||||
|
}
|
||||||
|
}
|
98
src/dns_updater.rs
Normal file
98
src/dns_updater.rs
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, bail, Result};
|
||||||
|
use log::*;
|
||||||
|
use tokio::select;
|
||||||
|
use tokio::sync::watch;
|
||||||
|
|
||||||
|
use crate::dns_config::*;
|
||||||
|
use crate::provider::DnsProvider;
|
||||||
|
|
||||||
|
pub async fn dns_updater_task(
|
||||||
|
mut rx_dns_config: watch::Receiver<Arc<DnsConfig>>,
|
||||||
|
provider: Box<dyn DnsProvider>,
|
||||||
|
allowed_domains: Vec<String>,
|
||||||
|
mut must_exit: watch::Receiver<bool>,
|
||||||
|
) {
|
||||||
|
let mut config = Arc::new(DnsConfig::new());
|
||||||
|
while !*must_exit.borrow() {
|
||||||
|
select!(
|
||||||
|
c = rx_dns_config.changed() => {
|
||||||
|
if c.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = must_exit.changed() => continue,
|
||||||
|
);
|
||||||
|
let new_config: Arc<DnsConfig> = rx_dns_config.borrow().clone();
|
||||||
|
|
||||||
|
for (k, v) in new_config.entries.iter() {
|
||||||
|
if config.entries.get(k) != Some(v) {
|
||||||
|
let fulldomain = format!("{}.{}", k.subdomain, k.domain);
|
||||||
|
if !allowed_domains.iter().any(|d| fulldomain.ends_with(d)) {
|
||||||
|
error!(
|
||||||
|
"Got an entry for domain {} which is not in allowed list",
|
||||||
|
k.domain
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Updating {} {}", k, v);
|
||||||
|
if let Err(e) = update_dns_entry(k, v, provider.as_ref()).await {
|
||||||
|
error!("Unable to update entry {} {}: {}", k, v, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config = new_config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_dns_entry(
|
||||||
|
key: &DnsEntryKey,
|
||||||
|
value: &DnsEntryValue,
|
||||||
|
provider: &dyn DnsProvider,
|
||||||
|
) -> Result<()> {
|
||||||
|
if value.targets.is_empty() {
|
||||||
|
bail!("zero targets (internal error)");
|
||||||
|
}
|
||||||
|
|
||||||
|
match key.record_type {
|
||||||
|
DnsRecordType::A => {
|
||||||
|
let mut targets = vec![];
|
||||||
|
for tgt in value.targets.iter() {
|
||||||
|
targets.push(
|
||||||
|
tgt.parse::<Ipv4Addr>()
|
||||||
|
.map_err(|_| anyhow!("Invalid ipv4 address: {}", tgt))?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
provider
|
||||||
|
.update_a(&key.domain, &key.subdomain, &targets)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
DnsRecordType::AAAA => {
|
||||||
|
let mut targets = vec![];
|
||||||
|
for tgt in value.targets.iter() {
|
||||||
|
targets.push(
|
||||||
|
tgt.parse::<Ipv6Addr>()
|
||||||
|
.map_err(|_| anyhow!("Invalid ipv6 address: {}", tgt))?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
provider
|
||||||
|
.update_aaaa(&key.domain, &key.subdomain, &targets)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
DnsRecordType::CNAME => {
|
||||||
|
let mut targets = value.targets.iter().cloned().collect::<Vec<_>>();
|
||||||
|
if targets.len() > 1 {
|
||||||
|
targets.sort();
|
||||||
|
warn!("Several CNAME targets for {}: {:?}. Taking first one in alphabetical order. Consider switching to a single global target instead.", key, targets);
|
||||||
|
}
|
||||||
|
provider
|
||||||
|
.update_cname(&key.domain, &key.subdomain, &targets[0])
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
143
src/main.rs
Normal file
143
src/main.rs
Normal file
|
@ -0,0 +1,143 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use log::*;
|
||||||
|
use structopt::StructOpt;
|
||||||
|
use tokio::select;
|
||||||
|
use tokio::sync::watch;
|
||||||
|
|
||||||
|
mod dns_config;
|
||||||
|
mod dns_updater;
|
||||||
|
mod provider;
|
||||||
|
|
||||||
|
#[derive(StructOpt, Debug)]
|
||||||
|
#[structopt(name = "d53")]
|
||||||
|
pub struct Opt {
|
||||||
|
/// Address of consul server
|
||||||
|
#[structopt(
|
||||||
|
long = "consul-addr",
|
||||||
|
env = "D53_CONSUL_HOST",
|
||||||
|
default_value = "http://127.0.0.1:8500"
|
||||||
|
)]
|
||||||
|
pub consul_addr: String,
|
||||||
|
|
||||||
|
/// CA certificate for Consul server with TLS
|
||||||
|
#[structopt(long = "consul-ca-cert", env = "D53_CONSUL_CA_CERT")]
|
||||||
|
pub consul_ca_cert: Option<String>,
|
||||||
|
|
||||||
|
/// Skip TLS verification for Consul
|
||||||
|
#[structopt(long = "consul-tls-skip-verify", env = "D53_CONSUL_TLS_SKIP_VERIFY")]
|
||||||
|
pub consul_tls_skip_verify: bool,
|
||||||
|
|
||||||
|
/// Client certificate for Consul server with TLS
|
||||||
|
#[structopt(long = "consul-client-cert", env = "D53_CONSUL_CLIENT_CERT")]
|
||||||
|
pub consul_client_cert: Option<String>,
|
||||||
|
|
||||||
|
/// Client key for Consul server with TLS
|
||||||
|
#[structopt(long = "consul-client-key", env = "D53_CONSUL_CLIENT_KEY")]
|
||||||
|
pub consul_client_key: Option<String>,
|
||||||
|
|
||||||
|
/// DNS provider
|
||||||
|
#[structopt(long = "provider", env = "D53_PROVIDER")]
|
||||||
|
pub provider: String,
|
||||||
|
|
||||||
|
/// Allowed domains
|
||||||
|
#[structopt(long = "allowed-domains", env = "D53_ALLOWED_DOMAINS")]
|
||||||
|
pub allowed_domains: String,
|
||||||
|
|
||||||
|
/// API key for Gandi DNS provider
|
||||||
|
#[structopt(long = "gandi-api-key", env = "D53_GANDI_API_KEY")]
|
||||||
|
pub gandi_api_key: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
if std::env::var("RUST_LOG").is_err() {
|
||||||
|
std::env::set_var("RUST_LOG", "tricot=info")
|
||||||
|
}
|
||||||
|
pretty_env_logger::init();
|
||||||
|
|
||||||
|
// Abort on panic (same behavior as in Go)
|
||||||
|
std::panic::set_hook(Box::new(|panic_info| {
|
||||||
|
error!("{}", panic_info.to_string());
|
||||||
|
std::process::abort();
|
||||||
|
}));
|
||||||
|
|
||||||
|
let opt = Opt::from_args();
|
||||||
|
|
||||||
|
info!("Starting D53");
|
||||||
|
|
||||||
|
let (exit_signal, _) = watch_ctrl_c();
|
||||||
|
|
||||||
|
let consul_config = df_consul::ConsulConfig {
|
||||||
|
addr: opt.consul_addr.clone(),
|
||||||
|
ca_cert: opt.consul_ca_cert.clone(),
|
||||||
|
tls_skip_verify: opt.consul_tls_skip_verify,
|
||||||
|
client_cert: opt.consul_client_cert.clone(),
|
||||||
|
client_key: opt.consul_client_key.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let consul = df_consul::Consul::new(consul_config, "").expect("Cannot build Consul");
|
||||||
|
|
||||||
|
let provider: Box<dyn provider::DnsProvider> = match opt.provider.as_str() {
|
||||||
|
"gandi" => Box::new(
|
||||||
|
provider::gandi::GandiProvider::new(&opt).expect("Cannot initialize Gandi provier"),
|
||||||
|
),
|
||||||
|
p => panic!("Unsupported DNS provider: {}", p),
|
||||||
|
};
|
||||||
|
|
||||||
|
let allowed_domains = opt
|
||||||
|
.allowed_domains
|
||||||
|
.split(',')
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let rx_dns_config = dns_config::spawn_dns_config_task(consul.clone(), exit_signal.clone());
|
||||||
|
|
||||||
|
let updater_task = tokio::spawn(dns_updater::dns_updater_task(
|
||||||
|
rx_dns_config.clone(),
|
||||||
|
provider,
|
||||||
|
allowed_domains,
|
||||||
|
exit_signal.clone(),
|
||||||
|
));
|
||||||
|
let dump_task = tokio::spawn(dump_config_on_change(rx_dns_config, exit_signal));
|
||||||
|
|
||||||
|
updater_task.await.expect("Tokio task await failure");
|
||||||
|
dump_task.await.expect("Tokio task await failure");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn dump_config_on_change(
|
||||||
|
mut rx_dns_config: watch::Receiver<Arc<dns_config::DnsConfig>>,
|
||||||
|
mut must_exit: watch::Receiver<bool>,
|
||||||
|
) {
|
||||||
|
while !*must_exit.borrow() {
|
||||||
|
select!(
|
||||||
|
c = rx_dns_config.changed() => {
|
||||||
|
if c.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = must_exit.changed() => continue,
|
||||||
|
);
|
||||||
|
println!("---- DNS CONFIGURATION ----");
|
||||||
|
for (k, v) in rx_dns_config.borrow().entries.iter() {
|
||||||
|
println!(" {} {}", k, v);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a watch that contains `false`, and that changes
|
||||||
|
/// to `true` when a Ctrl+C signal is received.
|
||||||
|
pub fn watch_ctrl_c() -> (watch::Receiver<bool>, Arc<watch::Sender<bool>>) {
|
||||||
|
let (send_cancel, watch_cancel) = watch::channel(false);
|
||||||
|
let send_cancel = Arc::new(send_cancel);
|
||||||
|
let send_cancel_2 = send_cancel.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
tokio::signal::ctrl_c()
|
||||||
|
.await
|
||||||
|
.expect("failed to install CTRL+C signal handler");
|
||||||
|
info!("Received CTRL+C, shutting down.");
|
||||||
|
send_cancel.send(true).unwrap();
|
||||||
|
});
|
||||||
|
(watch_cancel, send_cancel_2)
|
||||||
|
}
|
102
src/provider/gandi.rs
Normal file
102
src/provider/gandi.rs
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use log::{info, warn};
|
||||||
|
use reqwest::header;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::provider::DnsProvider;
|
||||||
|
use crate::Opt;
|
||||||
|
|
||||||
|
pub struct GandiProvider {
|
||||||
|
client: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GandiProvider {
|
||||||
|
pub fn new(opts: &Opt) -> Result<Self> {
|
||||||
|
let api_key = opts
|
||||||
|
.gandi_api_key
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| anyhow!("Must specify D53_GANDI_API_KEY"))?;
|
||||||
|
|
||||||
|
let mut headers = header::HeaderMap::new();
|
||||||
|
let mut auth_value = header::HeaderValue::from_str(&format!("Apikey {}", api_key))?;
|
||||||
|
auth_value.set_sensitive(true);
|
||||||
|
headers.insert(header::AUTHORIZATION, auth_value);
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.default_headers(headers)
|
||||||
|
.use_rustls_tls()
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
Ok(Self { client })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn put_rrset(&self, url: &str, rrset: &GandiRrset) -> Result<()> {
|
||||||
|
info!("PUT {} with {:?}", url, rrset);
|
||||||
|
let http = self.client.put(url).json(rrset).send().await?;
|
||||||
|
|
||||||
|
if !http.status().is_success() {
|
||||||
|
warn!("PUT {} returned {}", url, http.status());
|
||||||
|
}
|
||||||
|
|
||||||
|
http.error_for_status()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl DnsProvider for GandiProvider {
|
||||||
|
fn provider(&self) -> &'static str {
|
||||||
|
"gandi"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_a(&self, domain: &str, subdomain: &str, targets: &[Ipv4Addr]) -> Result<()> {
|
||||||
|
let url = format!(
|
||||||
|
"https://api.gandi.net/v5/livedns/domains/{}/records/{}/A",
|
||||||
|
domain, subdomain
|
||||||
|
);
|
||||||
|
|
||||||
|
let rrset = GandiRrset {
|
||||||
|
rrset_values: targets.iter().map(ToString::to_string).collect::<Vec<_>>(),
|
||||||
|
rrset_ttl: 300,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.put_rrset(&url, &rrset).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_aaaa(&self, domain: &str, subdomain: &str, targets: &[Ipv6Addr]) -> Result<()> {
|
||||||
|
let url = format!(
|
||||||
|
"https://api.gandi.net/v5/livedns/domains/{}/records/{}/AAAA",
|
||||||
|
domain, subdomain
|
||||||
|
);
|
||||||
|
|
||||||
|
let rrset = GandiRrset {
|
||||||
|
rrset_values: targets.iter().map(ToString::to_string).collect::<Vec<_>>(),
|
||||||
|
rrset_ttl: 300,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.put_rrset(&url, &rrset).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_cname(&self, domain: &str, subdomain: &str, target: &str) -> Result<()> {
|
||||||
|
let url = format!(
|
||||||
|
"https://api.gandi.net/v5/livedns/domains/{}/records/{}/CNAME",
|
||||||
|
domain, subdomain
|
||||||
|
);
|
||||||
|
|
||||||
|
let rrset = GandiRrset {
|
||||||
|
rrset_values: vec![target.to_string()],
|
||||||
|
rrset_ttl: 300,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.put_rrset(&url, &rrset).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Debug)]
|
||||||
|
struct GandiRrset {
|
||||||
|
rrset_values: Vec<String>,
|
||||||
|
rrset_ttl: u32,
|
||||||
|
}
|
20
src/provider/mod.rs
Normal file
20
src/provider/mod.rs
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
pub mod gandi;
|
||||||
|
|
||||||
|
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait DnsProvider: Send + Sync {
|
||||||
|
fn provider(&self) -> &'static str;
|
||||||
|
async fn update_a(&self, domain: &str, subdomain: &str, targets: &[Ipv4Addr]) -> Result<()>;
|
||||||
|
async fn update_aaaa(&self, domain: &str, subdomain: &str, targets: &[Ipv6Addr]) -> Result<()>;
|
||||||
|
async fn update_cname(&self, domain: &str, subdomain: &str, target: &str) -> Result<()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for dyn DnsProvider {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
|
||||||
|
write!(f, "DnsProvider({})", self.provider())
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue