tendns 0.2.0

Encrypted TXT discovery and hexadecimal IPv4 resolution, as a library and DNS server
Documentation
#![cfg(feature = "cli")]

use std::{
    fs,
    path::PathBuf,
    process::{Command, Output},
};

use tendns::{
    config::Config,
    names::canonical_name,
    protocol::{Entry, Publication, Secret, Value, decrypt},
    resolver::{Clock, SystemClock},
};

const KEY: &str = "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc";

struct Fixture {
    _directory: tempfile::TempDir,
    path: PathBuf,
}

impl Fixture {
    fn new(text: &str) -> Self {
        let directory = tempfile::tempdir().unwrap();
        let path = directory.path().join("tendns.toml");
        fs::write(&path, text).unwrap();
        Self {
            _directory: directory,
            path,
        }
    }

    fn basic() -> Self {
        Self::new(&format!(
            "valid_for='2h'\n[[discovery]]\ndomain='example.com'\nkey='{KEY}'"
        ))
    }

    fn run(&self, args: &[&str]) -> Output {
        Command::new(env!("CARGO_BIN_EXE_tendns"))
            .arg("--config")
            .arg(&self.path)
            .args(args)
            .output()
            .unwrap()
    }

    fn publication(&self, name: &str, args: &[&str]) -> Publication {
        let output = self.run(args);
        assert!(
            output.status.success(),
            "{}",
            String::from_utf8_lossy(&output.stderr)
        );
        assert!(output.stderr.is_empty());
        let output = String::from_utf8(output.stdout).unwrap();
        assert_eq!(output.lines().count(), 1);
        assert!(output.starts_with("td1."));
        assert!(!output.contains(KEY));
        let config = Config::load(&self.path).unwrap();
        let owner = canonical_name(name).unwrap();
        decrypt(
            output.trim().as_bytes(),
            &config.discovery_for(&owner).unwrap().key,
            &owner,
            SystemClock.now().unwrap(),
        )
        .unwrap()
    }
}

#[test]
fn offline_cli_generates_mixed_publication_with_explicit_validity() {
    let fixture = Fixture::basic();
    let publication = fixture.publication(
        "example.com",
        &[
            "txt",
            "--name",
            "EXAMPLE.COM",
            "--entry",
            "@:A:192.0.2.1",
            "--entry",
            "nas:AAAA:2001:db8::1",
            "--entry",
            "www:CNAME:target.net",
            "--valid-for",
            "1h",
        ],
    );
    assert_eq!(publication.expires_at - publication.issued_at, 3600);
    assert_eq!(
        publication.entries,
        vec![
            Entry {
                subdomain: String::new(),
                value: Value::A("192.0.2.1".parse().unwrap())
            },
            Entry {
                subdomain: "nas".into(),
                value: Value::Aaaa("2001:db8::1".parse().unwrap())
            },
            Entry {
                subdomain: "www".into(),
                value: Value::Cname(canonical_name("target.net").unwrap())
            },
        ]
    );
}

#[test]
fn repeated_address_flags_apply_hidden_label_and_configured_validity() {
    let fixture = Fixture::basic();
    let publication = fixture.publication(
        "example.com",
        &[
            "txt",
            "--name",
            "example.com",
            "--subdomain",
            "NAS",
            "--a",
            "192.0.2.1",
            "--a",
            "192.0.2.2",
            "--aaaa",
            "2001:db8::1",
            "--aaaa",
            "2001:db8::2",
        ],
    );
    assert_eq!(publication.expires_at - publication.issued_at, 7200);
    assert_eq!(publication.entries.len(), 4);
    assert!(publication.entries.iter().all(|e| e.subdomain == "nas"));
    assert_eq!(
        publication.entries[1].value,
        Value::A("192.0.2.2".parse().unwrap())
    );
    assert_eq!(
        publication.entries[3].value,
        Value::Aaaa("2001:db8::2".parse().unwrap())
    );
    let alias = fixture.publication(
        "example.com",
        &[
            "txt",
            "--name",
            "example.com",
            "--cname",
            "TARGET.NET",
            "--subdomain",
            "WWW",
        ],
    );
    assert_eq!(
        alias.entries,
        vec![Entry {
            subdomain: "www".into(),
            value: Value::Cname(canonical_name("target.net").unwrap())
        }]
    );
}

#[test]
fn txt_uses_the_most_specific_domains_distinct_key() {
    let child_key = Secret::generate().unwrap();
    let fixture = Fixture::new(&format!(
        "[[discovery]]\ndomain='example.com'\nkey='{KEY}'\n[[discovery]]\ndomain='inner.example.com'\nkey='{child_key}'"
    ));
    let output = fixture.run(&["txt", "--name", "a.inner.example.com", "--a", "192.0.2.1"]);
    assert!(output.status.success());
    let value = String::from_utf8(output.stdout).unwrap();
    let owner = canonical_name("a.inner.example.com").unwrap();
    let now = SystemClock.now().unwrap();
    assert!(
        decrypt(
            value.trim().as_bytes(),
            &Secret::decode(&child_key).unwrap(),
            &owner,
            now
        )
        .is_ok()
    );
    assert!(
        decrypt(
            value.trim().as_bytes(),
            &Secret::decode(KEY).unwrap(),
            &owner,
            now
        )
        .is_err()
    );
}

#[test]
fn invalid_txt_requests_fail_without_output_or_secret_disclosure() {
    let fixture = Fixture::basic();
    for args in [
        vec!["txt", "--name", "example.com"],
        vec!["txt", "--name", "other.net", "--a", "192.0.2.1"],
        vec![
            "txt",
            "--name",
            "example.com",
            "--subdomain",
            "a.b",
            "--a",
            "192.0.2.1",
        ],
        vec!["txt", "--name", "example.com", "--entry", "a.b:A:192.0.2.1"],
        vec!["txt", "--name", "example.com", "--a", "not-an-ip"],
        vec!["txt", "--name", "example.com", "--aaaa", "192.0.2.1"],
        vec!["txt", "--name", "example.com", "--cname", "a..net"],
        vec![
            "txt",
            "--name",
            "example.com",
            "--a",
            "192.0.2.1",
            "--cname",
            "target.net",
        ],
        vec!["txt", "--name", "example.com", "--entry", "a:MX:target.net"],
        vec![
            "txt",
            "--name",
            "example.com",
            "--a",
            "192.0.2.1",
            "--valid-for",
            "0s",
        ],
        vec![
            "txt",
            "--name",
            "example.com",
            "--a",
            "192.0.2.1",
            "--valid-for",
            "500ms",
        ],
        vec![
            "txt",
            "--name",
            "example.com",
            "--a",
            "192.0.2.1",
            "--valid-for",
            "invalid",
        ],
        vec!["serve"],
    ] {
        let output = fixture.run(&args);
        assert!(!output.status.success(), "{args:?}");
        assert!(output.stdout.is_empty(), "{args:?}");
        assert!(!output.stderr.is_empty());
        assert!(!String::from_utf8_lossy(&output.stderr).contains(KEY));
    }
}

#[test]
fn malformed_config_diagnostics_do_not_echo_keys() {
    let fixture = Fixture::new(&format!("[[discovery]]\ndomain='example.com'\nkey='{KEY}"));
    let output = fixture.run(&["txt", "--name", "example.com", "--a", "192.0.2.1"]);
    assert!(!output.status.success());
    assert!(output.stdout.is_empty());
    assert!(!String::from_utf8_lossy(&output.stderr).contains(KEY));
}

#[test]
fn keygen_needs_no_config_and_does_not_repeat() {
    let mut keys = Vec::new();
    let directory = tempfile::tempdir().unwrap();
    for _ in 0..2 {
        let result = Command::new(env!("CARGO_BIN_EXE_tendns"))
            .arg("--config")
            .arg(directory.path().join("nonexistent.toml"))
            .arg("keygen")
            .output()
            .unwrap();
        assert!(result.status.success());
        assert!(result.stderr.is_empty());
        let key = String::from_utf8(result.stdout).unwrap();
        assert_eq!(key.lines().count(), 1);
        assert_eq!(key.trim().len(), 43);
        Secret::decode(key.trim()).unwrap();
        keys.push(key);
    }
    assert_ne!(keys[0], keys[1]);
}

#[test]
fn cloudflare_commands_require_an_environment_token_without_disclosing_keys() {
    let fixture = Fixture::basic();
    for args in [
        vec![
            "cloudflare-set",
            "--name",
            "nas.example.com",
            "--a",
            "192.0.2.1",
        ],
        vec![
            "cloudflare-remove",
            "--name",
            "nas.example.com",
            "--type",
            "A",
        ],
    ] {
        let output = Command::new(env!("CARGO_BIN_EXE_tendns"))
            .arg("--config")
            .arg(&fixture.path)
            .args(args)
            .env_remove("CLOUDFLARE_API_TOKEN")
            .output()
            .unwrap();
        assert!(!output.status.success());
        assert!(output.stdout.is_empty());
        let error = String::from_utf8(output.stderr).unwrap();
        assert!(error.contains("CLOUDFLARE_API_TOKEN"));
        assert!(!error.contains(KEY));
    }
}