diff --git a/.gitignore b/.gitignore index 855a698..142ffb3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ target/ *.swp +.env diff --git a/Cargo.lock b/Cargo.lock index 7a050b4..bd1fca3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -140,6 +140,7 @@ name = "diplonat" version = "0.1.0" dependencies = [ "anyhow", + "envy", "futures", "igd", "iptables", @@ -181,6 +182,15 @@ dependencies = [ "termcolor", ] +[[package]] +name = "envy" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f47e0157f2cb54f5ae1bd371b30a2ae4311e1c028f575cd4e81de7353215965" +dependencies = [ + "serde", +] + [[package]] name = "fnv" version = "1.0.6" diff --git a/Cargo.toml b/Cargo.toml index a2a9667..b99d81a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,15 +7,16 @@ edition = "2018" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -reqwest = { version = "0.10", features = ["json"] } +anyhow = "1.0.28" +envy = "0.4" +futures = "0.3.5" igd = { version = "0.10.0", features = ["aio"] } +iptables = "0.2.2" log = "0.4" pretty_env_logger = "0.4" -tokio = "0.2" -futures = "0.3.5" -serde = { version = "1.0.107", features = ["derive"] } -serde_json = "1.0.53" -serde-lexpr = "0.1.1" -anyhow = "1.0.28" -iptables = "0.2.2" regex = "1" +reqwest = { version = "0.10", features = ["json"] } +serde = { version = "1.0.107", features = ["derive"] } +serde-lexpr = "0.1.1" +serde_json = "1.0.53" +tokio = "0.2" diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..14926bd --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,11 @@ +mod options; +#[cfg(test)] +mod options_test; +mod runtime; + +pub use options::{ConfigOpts, ConfigOptsAcme, ConfigOptsBase, ConfigOptsConsul}; +pub use runtime::{RuntimeConfig, RuntimeConfigAcme, RuntimeConfigConsul, RuntimeConfigFirewall, RuntimeConfigIgd}; + +pub const EXPIRATION_TIME: u16 = 300; +pub const REFRESH_TIME: u16 = 60; +pub const CONSUL_URL: &str = "http://127.0.0.1:8500"; \ No newline at end of file diff --git a/src/config/options.rs b/src/config/options.rs new file mode 100644 index 0000000..36da475 --- /dev/null +++ b/src/config/options.rs @@ -0,0 +1,76 @@ +use anyhow::Result; +use serde::Deserialize; + +use crate::config::RuntimeConfig; + +// This code is inspired by the Trunk crate (https://github.com/thedodd/trunk) + +// This file parses the options that can be declared in the environment. +// runtime.rs applies business logic and builds RuntimeConfig structs. + +/// Base configuration options +#[derive(Clone, Default, Deserialize)] +pub struct ConfigOptsBase { + /// This node's private IP address [default: None] + pub private_ip: Option, + /// Expiration time for IGD rules [default: 60] + pub expiration_time: Option, + /// Refresh time for IGD and Firewall rules [default: 300] + pub refresh_time: Option, +} + +/// ACME configuration options +#[derive(Clone, Default, Deserialize)] +pub struct ConfigOptsAcme { + /// Whether ACME is enabled [default: false] + #[serde(default)] + pub enable: bool, + + /// The default domain holder's e-mail [default: None] + pub email: Option, +} + +/// Consul configuration options +#[derive(Clone, Default, Deserialize)] +pub struct ConfigOptsConsul { + /// Consul's node name [default: None] + pub node_name: Option, + /// Consul's REST URL [default: "http://127.0.0.1:8500"] + pub url: Option, +} + +/// Model of all potential configuration options +pub struct ConfigOpts { + pub base: ConfigOptsBase, + pub acme: ConfigOptsAcme, + pub consul: ConfigOptsConsul, +} + +impl ConfigOpts { + pub fn from_env() -> Result { + let base: ConfigOptsBase = envy::prefixed("DIPLONAT_").from_env()?; + let consul: ConfigOptsConsul = envy::prefixed("DIPLONAT_CONSUL_").from_env()?; + let acme: ConfigOptsAcme = envy::prefixed("DIPLONAT_ACME_").from_env()?; + + RuntimeConfig::new(Self { + base: base, + consul: consul, + acme: acme, + }) + } + + // Currently only used in tests + #[allow(dead_code)] + pub fn from_iter(iter: Iter) -> Result + where Iter: IntoIterator { + let base: ConfigOptsBase = envy::prefixed("DIPLONAT_").from_iter(iter.clone())?; + let consul: ConfigOptsConsul = envy::prefixed("DIPLONAT_CONSUL_").from_iter(iter.clone())?; + let acme: ConfigOptsAcme = envy::prefixed("DIPLONAT_ACME_").from_iter(iter.clone())?; + + RuntimeConfig::new(Self { + base: base, + consul: consul, + acme: acme, + }) + } +} \ No newline at end of file diff --git a/src/config/options_test.rs b/src/config/options_test.rs new file mode 100644 index 0000000..a6063fd --- /dev/null +++ b/src/config/options_test.rs @@ -0,0 +1,120 @@ +use std::collections::HashMap; +use std::time::Duration; + +use crate::config::*; + +// Environment variables are set for the entire process and +// tests are run whithin the same process. +// => We cannot test ConfigOpts::from_env(), +// because tests modify each other's environment. +// This is why we only test ConfigOpts::from_iter(iter). + +fn minimal_valid_options() -> HashMap { + let mut opts = HashMap::new(); + opts.insert("DIPLONAT_PRIVATE_IP".to_string(), "172.123.43.555".to_string()); + opts.insert("DIPLONAT_CONSUL_NODE_NAME".to_string(), "consul_node".to_string()); + opts +} + +fn all_valid_options() -> HashMap { + let mut opts = minimal_valid_options(); + opts.insert("DIPLONAT_EXPIRATION_TIME".to_string(), "30".to_string()); + opts.insert("DIPLONAT_REFRESH_TIME".to_string(), "10".to_string()); + opts.insert("DIPLONAT_CONSUL_URL".to_string(), "http://127.0.0.1:9999".to_string()); + opts.insert("DIPLONAT_ACME_ENABLE".to_string(), "true".to_string()); + opts.insert("DIPLONAT_ACME_EMAIL".to_string(), "bozo@bozo.net".to_string()); + opts +} + +#[test] +#[should_panic] +fn err_empty_env() { + std::env::remove_var("DIPLONAT_PRIVATE_IP"); + std::env::remove_var("DIPLONAT_CONSUL_NODE_NAME"); + ConfigOpts::from_env().unwrap(); +} + +#[test] +fn ok_from_iter_minimal_valid_options() { + let opts = minimal_valid_options(); + let rt_config = ConfigOpts::from_iter(opts.clone()).unwrap(); + + assert!(rt_config.acme.is_none()); + assert_eq!( + &rt_config.consul.node_name, + opts.get(&"DIPLONAT_CONSUL_NODE_NAME".to_string()).unwrap() + ); + assert_eq!( + rt_config.consul.url, + CONSUL_URL.to_string() + ); + assert_eq!( + rt_config.firewall.refresh_time, + Duration::from_secs(REFRESH_TIME.into()) + ); + assert_eq!( + &rt_config.igd.private_ip, + opts.get(&"DIPLONAT_PRIVATE_IP".to_string()).unwrap() + ); + assert_eq!( + rt_config.igd.expiration_time, + Duration::from_secs(EXPIRATION_TIME.into()) + ); + assert_eq!( + rt_config.igd.refresh_time, + Duration::from_secs(REFRESH_TIME.into()) + ); +} + +#[test] +#[should_panic] +fn err_from_iter_invalid_refresh_time() { + let mut opts = minimal_valid_options(); + opts.insert("DIPLONAT_EXPIRATION_TIME".to_string(), "60".to_string()); + opts.insert("DIPLONAT_REFRESH_TIME".to_string(), "60".to_string()); + ConfigOpts::from_iter(opts).unwrap(); +} + +#[test] +fn ok_from_iter_all_valid_options() { + let opts = all_valid_options(); + let rt_config = ConfigOpts::from_iter(opts.clone()).unwrap(); + + let expiration_time = Duration::from_secs( + opts.get(&"DIPLONAT_EXPIRATION_TIME".to_string()).unwrap() + .parse::().unwrap() + .into()); + let refresh_time = Duration::from_secs( + opts.get(&"DIPLONAT_REFRESH_TIME".to_string()).unwrap() + .parse::().unwrap() + .into()); + + assert!(rt_config.acme.is_some()); + assert_eq!( + &rt_config.acme.unwrap().email, + opts.get(&"DIPLONAT_ACME_EMAIL".to_string()).unwrap()); + assert_eq!( + &rt_config.consul.node_name, + opts.get(&"DIPLONAT_CONSUL_NODE_NAME".to_string()).unwrap() + ); + assert_eq!( + &rt_config.consul.url, + opts.get(&"DIPLONAT_CONSUL_URL".to_string()).unwrap() + ); + assert_eq!( + rt_config.firewall.refresh_time, + refresh_time + ); + assert_eq!( + &rt_config.igd.private_ip, + opts.get(&"DIPLONAT_PRIVATE_IP".to_string()).unwrap() + ); + assert_eq!( + rt_config.igd.expiration_time, + expiration_time + ); + assert_eq!( + rt_config.igd.refresh_time, + refresh_time + ); +} \ No newline at end of file diff --git a/src/config/runtime.rs b/src/config/runtime.rs new file mode 100644 index 0000000..58c86b9 --- /dev/null +++ b/src/config/runtime.rs @@ -0,0 +1,121 @@ +use std::time::Duration; + +use anyhow::{Result, anyhow}; + +use crate::config::{ConfigOpts, ConfigOptsAcme, ConfigOptsBase, ConfigOptsConsul}; + +// This code is inspired by the Trunk crate (https://github.com/thedodd/trunk) + +// In this file, we take ConfigOpts and transform them into ready-to-use RuntimeConfig. +// We apply default values and business logic. + +#[derive(Debug)] +pub struct RuntimeConfigAcme { + pub email: String, +} + +#[derive(Debug)] +pub struct RuntimeConfigConsul { + pub node_name: String, + pub url: String, +} + +#[derive(Debug)] +pub struct RuntimeConfigFirewall { + pub refresh_time: Duration, +} + +#[derive(Debug)] +pub struct RuntimeConfigIgd { + pub private_ip: String, + pub expiration_time: Duration, + pub refresh_time: Duration, +} + +#[derive(Debug)] +pub struct RuntimeConfig { + pub acme: Option, + pub consul: RuntimeConfigConsul, + pub firewall: RuntimeConfigFirewall, + pub igd: RuntimeConfigIgd, +} + +impl RuntimeConfig { + pub fn new(opts: ConfigOpts) -> Result { + let acme = RuntimeConfigAcme::new(opts.acme.clone())?; + let consul = RuntimeConfigConsul::new(opts.consul.clone())?; + let firewall = RuntimeConfigFirewall::new(opts.base.clone())?; + let igd = RuntimeConfigIgd::new(opts.base.clone())?; + + Ok(Self { + acme, + consul, + firewall, + igd, + }) + } +} + +impl RuntimeConfigAcme { + pub fn new(opts: ConfigOptsAcme) -> Result> { + if !opts.enable { + return Ok(None); + } + + let email = opts.email.expect( + "'DIPLONAT_ACME_EMAIL' environment variable is required \ + if 'DIPLONAT_ACME_ENABLE' == 'true'"); + + Ok(Some(Self { + email, + })) + } +} + +impl RuntimeConfigConsul { + pub(super) fn new(opts: ConfigOptsConsul) -> Result { + let node_name = opts.node_name.expect( + "'DIPLONAT_CONSUL_NODE_NAME' environment variable is required"); + let url = opts.url.unwrap_or(super::CONSUL_URL.to_string()); + + Ok(Self { + node_name, + url, + }) + } +} + +impl RuntimeConfigFirewall { + pub(super) fn new(opts: ConfigOptsBase) -> Result { + let refresh_time = Duration::from_secs( + opts.refresh_time.unwrap_or(super::REFRESH_TIME).into()); + + Ok(Self { + refresh_time, + }) + } +} + +impl RuntimeConfigIgd { + pub(super) fn new(opts: ConfigOptsBase) -> Result { + let private_ip = opts.private_ip.expect( + "'DIPLONAT_PRIVATE_IP' environment variable is required"); + let expiration_time = Duration::from_secs( + opts.expiration_time.unwrap_or(super::EXPIRATION_TIME).into()); + let refresh_time = Duration::from_secs( + opts.refresh_time.unwrap_or(super::REFRESH_TIME).into()); + + if refresh_time.as_secs() * 2 > expiration_time.as_secs() { + return Err(anyhow!( + "IGD expiration time (currently: {}s) must be at least twice bigger than refresh time (currently: {}s)", + expiration_time.as_secs(), + refresh_time.as_secs())); + } + + Ok(Self { + private_ip, + expiration_time, + refresh_time, + }) + } +} \ No newline at end of file diff --git a/src/diplonat.rs b/src/diplonat.rs index 798b779..7049530 100644 --- a/src/diplonat.rs +++ b/src/diplonat.rs @@ -1,31 +1,35 @@ use anyhow::Result; use tokio::try_join; + +use crate::config::ConfigOpts; use crate::consul_actor::ConsulActor; -use crate::igd_actor::IgdActor; -use crate::environment::Environment; use crate::fw_actor::FirewallActor; +use crate::igd_actor::IgdActor; pub struct Diplonat { consul: ConsulActor, + firewall: FirewallActor, igd: IgdActor, - firewall: FirewallActor } impl Diplonat { pub async fn new() -> Result { - let env = Environment::new()?; - let ca = ConsulActor::new(&env.consul_url, &env.consul_node_name); - let ia = IgdActor::new( - &env.private_ip, - env.refresh_time, - env.expiration_time, - &ca.rx_open_ports - ).await?; + let rt_cfg = ConfigOpts::from_env()?; + println!("{:#?}", rt_cfg); + + let ca = ConsulActor::new(&rt_cfg.consul.url, &rt_cfg.consul.node_name); let fw = FirewallActor::new( - env.refresh_time, + rt_cfg.firewall.refresh_time, &ca.rx_open_ports ).await?; + + let ia = IgdActor::new( + &rt_cfg.igd.private_ip, + rt_cfg.igd.refresh_time, + rt_cfg.igd.expiration_time, + &ca.rx_open_ports + ).await?; let ctx = Self { consul: ca, diff --git a/src/environment.rs b/src/environment.rs deleted file mode 100644 index 335fa37..0000000 --- a/src/environment.rs +++ /dev/null @@ -1,56 +0,0 @@ -use std::env; -use anyhow::{Result, Context, anyhow}; -use std::time::Duration; -use log::*; - -const epi: &'static str = "DIPLONAT_PRIVATE_IP"; -const ert: &'static str = "DIPLONAT_REFRESH_TIME"; -const eet: &'static str = "DIPLONAT_EXPIRATION_TIME"; -const ecnd: &'static str = "DIPLONAT_CONSUL_NODE_NAME"; -const ecu: &'static str = "DIPLONAT_CONSUL_URL"; - -pub struct Environment { - pub consul_node_name: String, - pub consul_url: String, - - pub refresh_time: Duration, - pub expiration_time: Duration, - - pub private_ip: String, -} - -/* @FIXME: Rewrite with Serde Envi */ -impl Environment { - pub fn new() -> Result { - let ctx = Self { - consul_url: match env::var(ecu) { Ok(e) => e, Err(_) => "http://127.0.0.1:8500".to_string() }, - consul_node_name: env::var(ecnd).with_context(|| format!("{} env var must be defined", ecnd))?, - private_ip: env::var(epi).with_context(|| format!("{} env var must be defined, eg: 192.168.0.18", epi))?, - refresh_time: Duration::from_secs(env::var(ert) - .with_context(|| format!("{} env var must be defined, eg: 60", ert))? - .parse() - .with_context(|| format!("{} env var must be an integer, eg: 60", ert))?), - expiration_time: Duration::from_secs(env::var(eet) - .with_context(|| format!("{} env var must be defined, eg: 300", eet))? - .parse() - .with_context(|| format!("{} env var must be an integer, eg: 300", eet))?), - }; - - if ctx.refresh_time.as_secs() * 2 > ctx.expiration_time.as_secs() { - return Err(anyhow!( - "Expiration time (currently: {}s) must be twice bigger than refresh time (currently: {}s)", - ctx.refresh_time.as_secs(), - ctx.expiration_time.as_secs())); - } - - info!("Consul URL: {:#?}", ctx.consul_url); - info!("Consul node name: {:#?}", ctx.consul_node_name); - info!("Private IP address: {:#?}", ctx.private_ip); - info!("Refresh time: {:#?} seconds", ctx.refresh_time.as_secs()); - info!("Expiration time: {:#?} seconds", ctx.expiration_time.as_secs()); - - return Ok(ctx); - } -} - - diff --git a/src/igd_actor.rs b/src/igd_actor.rs index 68d20df..55d9c5f 100644 --- a/src/igd_actor.rs +++ b/src/igd_actor.rs @@ -25,8 +25,8 @@ impl IgdActor { pub async fn new(priv_ip: &str, refresh: Duration, expire: Duration, rxp: &watch::Receiver) -> Result { let gw = search_gateway(Default::default()) .await - .context("Failed to find gateway")?; - info!("Gateway: {}", gw); + .context("Failed to find IGD gateway")?; + info!("IGD gateway: {}", gw); let ctx = Self { gateway: gw, diff --git a/src/main.rs b/src/main.rs index ca36c26..720edf8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,11 @@ -mod messages; -mod environment; +mod config; mod consul; mod consul_actor; -mod igd_actor; mod diplonat; mod fw; mod fw_actor; +mod igd_actor; +mod messages; use log::*; use diplonat::Diplonat;