use std::collections::HashMap;
use std::env;
use std::fmt;
use std::fs::File;
use std::io::prelude::*;
use std::marker::PhantomData;
use std::vec::Vec;
use anyhow::{Context, Result};
use serde::de;
use serde::{Deserialize, Deserializer};
use tracing::{trace, warn};
/// The struct representation of an Kapiti filter section
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConfigFilter {
/// Zero or more file paths or URLs for override files, where URLs will be automatically updated periodically.
/// Each listed file should look similar to a `/etc/hosts` file: a newline-separated list of hostnames paired with IP destinations.
#[serde(default)]
#[serde(alias = "override")]
#[serde(deserialize_with = "string_or_seq_string")]
pub overrides: Vec<String>,
/// Zero or more file paths or URLs for block files, where URLs will be automatically updated periodically.
/// Each listed file should contain a newline-separated list of hostnames to be blocked (along with their subdomains).
/// This cannot be combined with `allows` in the same filter object.
#[serde(default)]
#[serde(alias = "block")]
#[serde(deserialize_with = "string_or_seq_string")]
pub blocks: Vec<String>,
/// Zero or more file paths or URLs for allow files, where URLs will be automatically updated periodically.
/// Each listed file should contain a newline-separated list of hostnames to be allowed (along with their subdomains).
/// This cannot be combined with `blocks` in the same filter object.
#[serde(default)]
#[serde(alias = "allow")]
#[serde(deserialize_with = "string_or_seq_string")]
pub allows: Vec<String>,
/// The hosts that this filter block should apply to, or empty/unset for all requests (unless another filter has a closer `applies_to`).
/// May either be a single IP (`192.16.1.2`) or a range of IPs via CIDR notation (`192.16.1.0/24`).
/// No two filter blocks may have the same `applies_to` setting.
/// The filter block with the "closest" match for a given host is the one that will be evaluated.
#[serde(default)]
#[serde(alias = "apply_to")]
pub applies_to: String,
}
/// The struct representation of an Kapiti TOML config file.
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
/// Where temporary files, such as downloaded override/block files, should be stored.
/// Defaults to `/tmp/kapiti`.
#[serde(default = "default_storage")]
pub storage: String,
/// If the service is started as the root user, what user it should "downgrade" to.
/// Defaults to `nobody`, or may be set to an empty string to disable.
#[serde(default = "default_user")]
pub user: String,
/// Number of worker threads for processing queries. Higher values allow more queries
/// to be handled simultaneously at the cost of more resource consumption.
#[serde(default = "default_workers")]
pub workers: u64,
/// IP+Port endpoint for the DNS server.
/// Defaults to `0.0.0.0:53`.
#[serde(default = "default_listen_dns")]
pub listen_dns: String,
/// How long to wait between checks for changes to local and remote filters.
#[serde(default = "default_filter_refresh_seconds")]
pub filter_refresh_seconds: u64,
/// Filters to apply against request.
/// Each filter block may optionally specify applicable client IPs for that block.
#[serde(default)]
#[serde(alias = "filter")]
pub filters: HashMap<String, ConfigFilter>,
/// Equivalent to `ConfigFilter.overrides` with an empty `ConfigFilter.applies_to`.
#[serde(default)]
#[serde(alias = "override")]
#[serde(deserialize_with = "string_or_seq_string")]
pub overrides: Vec<String>,
/// Equivalent to `ConfigFilter.blocks` with an empty `ConfigFilter.applies_to`.
#[serde(default)]
#[serde(alias = "block")]
#[serde(deserialize_with = "string_or_seq_string")]
pub blocks: Vec<String>,
/// Equivalent to `ConfigFilter.allows` with an empty `ConfigFilter.applies_to`.
#[serde(default)]
#[serde(alias = "allow")]
#[serde(deserialize_with = "string_or_seq_string")]
pub allows: Vec<String>,
/// One or more DNS server endpoints.
/// These may be provided as hostnames (required for DNS-over-HTTPS), but at least one upstream entry must be provided as an IP to "bootstrap" any hostname entries.
/// Entries are in order of priority, where earlier entries are used before trying later entries. The following protocols are supported:
/// - `127.0.0.1[:53]` for a "classic" port 53 UDP+TCP endpoint, where UDP will take priority
/// - `udp://127.0.0.1[:53]` for a "classic" port 53 endpoint that's UDP-only
/// - `tcp://127.0.0.1[:53]` for a "classic" port 53 endpoint that's TCP-only
/// - `https://example.com[:443]/dns-query` or `https://127.0.0.1[:443]/dns-query` for a DNS-over-HTTP endpoint, see https://github.com/curl/curl/wiki/DNS-over-HTTPS#publicly-available-servers
/// - `tls://127.0.0.1[:853]` for a DNS-over-TLS endpoint, see https://dnsprivacy.org/public_resolvers/#dns-over-tls-dot
#[serde(default)]
#[serde(alias = "upstream")]
#[serde(deserialize_with = "string_or_seq_string")]
pub upstreams: Vec<String>,
/// URL endpoint for a Redis cache.
/// For example `redis://127.0.0.1:6379/0`, or `redis://user:password@redis.local:6379/0`.
/// Or empty/unset to disable Redis in favor of an internal in-memory cache.
#[serde(default)]
pub redis: String,
/// Max records for internal in-memory cache, or 0 for no limit.
/// The cache is automatically pruned of records as they expire, so this limit shouldn't be reached except for heavier workloads.
/// This is ignored if `redis` is provided.
#[serde(default = "default_cache_size")]
pub cache_size: usize,
}
impl Config {
/// Returns a new `Config` instance suitable for use in benchmark tests.
/// Most values are empty or left as their defaults, while the "listen" value is set to `127.0.0.1:0` for an ephemeral port.
pub fn new_for_test(storage: &str, upstream: String) -> Config {
let listen_random = "127.0.0.1:0".to_string();
Config {
storage: storage.to_string(),
// Disable user downgrade to avoid system-specific issues (what if 'nobody' doesn't exist in the test environment?)
user: "".to_string(),
workers: default_workers(),
listen_dns: listen_random,
upstreams: vec![upstream],
filter_refresh_seconds: default_filter_refresh_seconds(),
filters: HashMap::new(),
overrides: Vec::new(),
blocks: Vec::new(),
allows: Vec::new(),
redis: "".to_string(),
cache_size: default_cache_size(),
}
}
}
fn default_storage() -> String {
"/tmp/kapiti".to_string()
}
fn default_user() -> String {
"nobody".to_string()
}
fn default_workers() -> u64 {
10
}
fn default_listen_dns() -> String {
"0.0.0.0:53".to_string()
}
fn default_filter_refresh_seconds() -> u64 {
3 * 60 * 60 // 3 hours
}
fn default_cache_size() -> usize {
100000 // 100k feels like it should be plenty
}
/// A config value that can be provided either as a string or a list of strings in the TOML file.
/// We convert both cases to a `Vec<String>` automatically.
fn string_or_seq_string<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: Deserializer<'de>,
{
struct StringOrVec(PhantomData<Vec<String>>);
impl<'de> de::Visitor<'de> for StringOrVec {
type Value = Vec<String>;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("string or list of strings")
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(vec![value.to_owned()])
}
fn visit_seq<S>(self, visitor: S) -> Result<Self::Value, S::Error>
where
S: de::SeqAccess<'de>,
{
Deserialize::deserialize(de::value::SeqAccessDeserializer::new(visitor))
}
}
deserializer.deserialize_any(StringOrVec(PhantomData))
}
pub fn parse_config_file(config_path: &String) -> Result<Config> {
let mut config = String::new();
File::open(&config_path)?.read_to_string(&mut config)?;
// For each "CONFIG_FOO" envvar, replace any instances of "{{FOO}}" with the envvar value
for (oskey, osvalue) in env::vars_os() {
if let Some(key) = oskey.to_str() {
if !key.starts_with("CONFIG_") {
continue;
}
if let Some(value) = osvalue.to_str() {
let replaceme = format!("{{{{{}}}}}", key.to_string()[7..].to_string());
config = config.replace(replaceme.as_str(), value);
} else {
warn!(
"Envvar {} value is not valid UTF8, templating disabled",
key
);
}
}
}
trace!("Rendered config:\n{}", config);
toml::from_str(config.as_str()).with_context(|| "Failed to parse TOML config")
}