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