Merge pull request #4 from japaric/container-api
minimal local network with working resolution
This commit is contained in:
commit
b21875b963
215
Cargo.lock
generated
215
Cargo.lock
generated
|
@ -2,6 +2,221 @@
|
|||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed570934406eb16438a4e976b1b4500774099c13b8cb96eec99f620f05090ddf"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
|
||||
|
||||
[[package]]
|
||||
name = "dnssec-tests"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"minijinja",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.153"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c"
|
||||
|
||||
[[package]]
|
||||
name = "minijinja"
|
||||
version = "1.0.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6fe0ff215195a22884d867b547c70a0c4815cbbcc70991f281dca604b20d10ce"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.78"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "0.38.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ea3e1a662af26cd7a3ba09c0297a31af215563ecf42817c98df621387f4e949"
|
||||
dependencies = [
|
||||
"bitflags 2.4.2",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.196"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "870026e60fa08c69f064aa766c10f10b1d62db9ccd4d0abb206472bee0ce3b32"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.196"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33c85360c95e7d137454dc81d9a4ed2b8efd8fbe19cee57357b32b9771fccb67"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.48"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01ce4141aa927a6d1bd34a041795abd0db1cccba5d5f24b009f694bdf3a1f3fa"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"fastrand",
|
||||
"redox_syscall",
|
||||
"rustix",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a18201040b24831fbb9e4eb208f8892e1f50a37feb53cc7ff887feb8f50e7cd"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04"
|
||||
|
|
|
@ -4,6 +4,9 @@ version = "0.1.0"
|
|||
edition = "2021"
|
||||
license = "MIT or Apache 2.0"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
minijinja = "1.0.12"
|
||||
tempfile = "3.9.0"
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
|
|
12
README.md
12
README.md
|
@ -81,7 +81,7 @@ remote-control:
|
|||
control-enable: no
|
||||
|
||||
zone:
|
||||
name: .
|
||||
name: main
|
||||
zonefile: /etc/nsd/zones/main.zone
|
||||
```
|
||||
|
||||
|
@ -91,11 +91,11 @@ zone:
|
|||
$ORIGIN com.
|
||||
$TTL 1800
|
||||
@ IN SOA primary.tld-server.com. admin.tld-server.com. (
|
||||
2014080301
|
||||
3600
|
||||
900
|
||||
1209600
|
||||
1800
|
||||
2014010100 ; Serial
|
||||
10800 ; Refresh (3 hours)
|
||||
900 ; Retry (15 minutes)
|
||||
604800 ; Expire (1 week)
|
||||
86400 ; Minimum (1 day)
|
||||
)
|
||||
@ IN NS primary.tld-server.com.
|
||||
```
|
||||
|
|
|
@ -1,4 +0,0 @@
|
|||
FROM ubuntu:22.04
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y dnsutils iputils-ping tshark vim
|
|
@ -1,2 +0,0 @@
|
|||
. 3600000 NS primary.root-server.com.
|
||||
primary.root-server.com. 3600000 A 172.17.0.2
|
|
@ -1,4 +0,0 @@
|
|||
FROM ubuntu:22.04
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y nsd iputils-ping tshark vim
|
|
@ -1,7 +1,4 @@
|
|||
FROM ubuntu:22.04
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y unbound iputils-ping tshark vim
|
||||
|
||||
COPY ./files/etc/unbound/unbound.conf /etc/unbound/unbound.conf
|
||||
COPY ./files/etc/unbound/root.hints /etc/unbound/root.hints
|
||||
apt-get install -y dnsutils unbound nsd iputils-ping tshark vim ldnsutils
|
||||
|
|
277
src/client.rs
Normal file
277
src/client.rs
Normal file
|
@ -0,0 +1,277 @@
|
|||
use core::str::FromStr;
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
use crate::container::Container;
|
||||
use crate::record::{Record, RecordType};
|
||||
use crate::{Error, Result, FQDN};
|
||||
|
||||
pub struct Client {
|
||||
inner: Container,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
pub fn new() -> Result<Self> {
|
||||
Ok(Self {
|
||||
inner: Container::run()?,
|
||||
})
|
||||
}
|
||||
|
||||
// FIXME this needs to use the same trust anchor as `RecursiveResolver` or validation will fail
|
||||
pub fn delv(
|
||||
&self,
|
||||
server: Ipv4Addr,
|
||||
record_type: RecordType,
|
||||
fqdn: &FQDN<'_>,
|
||||
) -> Result<String> {
|
||||
self.inner.stdout(&[
|
||||
"delv",
|
||||
"+mtrace",
|
||||
&format!("@{server}"),
|
||||
record_type.as_str(),
|
||||
fqdn.as_str(),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn dig(
|
||||
&self,
|
||||
recurse: Recurse,
|
||||
dnssec: Dnssec,
|
||||
server: Ipv4Addr,
|
||||
record_type: RecordType,
|
||||
fqdn: &FQDN<'_>,
|
||||
) -> Result<DigOutput> {
|
||||
let output = self.inner.stdout(&[
|
||||
"dig",
|
||||
recurse.as_str(),
|
||||
dnssec.as_str(),
|
||||
&format!("@{server}"),
|
||||
record_type.as_str(),
|
||||
fqdn.as_str(),
|
||||
])?;
|
||||
|
||||
output.parse()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum Dnssec {
|
||||
Yes,
|
||||
No,
|
||||
}
|
||||
|
||||
impl Dnssec {
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Yes => "+dnssec",
|
||||
Self::No => "+nodnssec",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum Recurse {
|
||||
Yes,
|
||||
No,
|
||||
}
|
||||
|
||||
impl Recurse {
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Yes => "+recurse",
|
||||
Self::No => "+norecurse",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DigOutput {
|
||||
pub flags: DigFlags,
|
||||
pub status: DigStatus,
|
||||
pub answer: Vec<Record>,
|
||||
// TODO(if needed) other sections
|
||||
}
|
||||
|
||||
impl FromStr for DigOutput {
|
||||
type Err = Error;
|
||||
|
||||
fn from_str(input: &str) -> Result<Self> {
|
||||
const FLAGS_PREFIX: &str = ";; flags: ";
|
||||
const STATUS_PREFIX: &str = ";; ->>HEADER<<- opcode: QUERY, status: ";
|
||||
const ANSWER_HEADER: &str = ";; ANSWER SECTION:";
|
||||
|
||||
fn not_found(prefix: &str) -> String {
|
||||
format!("`{prefix}` line was not found")
|
||||
}
|
||||
|
||||
fn more_than_once(prefix: &str) -> String {
|
||||
format!("`{prefix}` line was found more than once")
|
||||
}
|
||||
|
||||
fn missing(prefix: &str, delimiter: &str) -> String {
|
||||
format!("`{prefix}` line is missing a {delimiter}")
|
||||
}
|
||||
|
||||
let mut flags = None;
|
||||
let mut status = None;
|
||||
let mut answer = None;
|
||||
|
||||
let mut lines = input.lines();
|
||||
while let Some(line) = lines.next() {
|
||||
if let Some(unprefixed) = line.strip_prefix(FLAGS_PREFIX) {
|
||||
let (flags_text, _rest) = unprefixed
|
||||
.split_once(';')
|
||||
.ok_or_else(|| missing(FLAGS_PREFIX, "semicolon (;)"))?;
|
||||
|
||||
if flags.is_some() {
|
||||
return Err(more_than_once(FLAGS_PREFIX).into());
|
||||
}
|
||||
|
||||
flags = Some(flags_text.parse()?);
|
||||
} else if let Some(unprefixed) = line.strip_prefix(STATUS_PREFIX) {
|
||||
let (status_text, _rest) = unprefixed
|
||||
.split_once(',')
|
||||
.ok_or_else(|| missing(STATUS_PREFIX, "comma (,)"))?;
|
||||
|
||||
if status.is_some() {
|
||||
return Err(more_than_once(STATUS_PREFIX).into());
|
||||
}
|
||||
|
||||
status = Some(status_text.parse()?);
|
||||
} else if line.starts_with(ANSWER_HEADER) {
|
||||
if answer.is_some() {
|
||||
return Err(more_than_once(ANSWER_HEADER).into());
|
||||
}
|
||||
|
||||
let mut records = vec![];
|
||||
for line in lines.by_ref() {
|
||||
if line.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
records.push(line.parse()?);
|
||||
}
|
||||
|
||||
answer = Some(records);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
flags: flags.ok_or_else(|| not_found(FLAGS_PREFIX))?,
|
||||
status: status.ok_or_else(|| not_found(STATUS_PREFIX))?,
|
||||
answer: answer.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
pub struct DigFlags {
|
||||
pub qr: bool,
|
||||
pub recursion_desired: bool,
|
||||
pub recursion_available: bool,
|
||||
pub authoritative_answer: bool,
|
||||
pub authenticated_data: bool,
|
||||
}
|
||||
|
||||
impl FromStr for DigFlags {
|
||||
type Err = Error;
|
||||
|
||||
fn from_str(input: &str) -> std::prelude::v1::Result<Self, Self::Err> {
|
||||
let mut qr = false;
|
||||
let mut recursion_desired = false;
|
||||
let mut recursion_available = false;
|
||||
let mut authoritative_answer = false;
|
||||
let mut authenticated_data = false;
|
||||
|
||||
for flag in input.split_whitespace() {
|
||||
match flag {
|
||||
"qr" => qr = true,
|
||||
"rd" => recursion_desired = true,
|
||||
"ra" => recursion_available = true,
|
||||
"aa" => authoritative_answer = true,
|
||||
"ad" => authenticated_data = true,
|
||||
_ => return Err(format!("unknown flag: {flag}").into()),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
qr,
|
||||
recursion_desired,
|
||||
recursion_available,
|
||||
authoritative_answer,
|
||||
authenticated_data,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum DigStatus {
|
||||
NOERROR,
|
||||
NXDOMAIN,
|
||||
REFUSED,
|
||||
}
|
||||
|
||||
impl DigStatus {
|
||||
#[must_use]
|
||||
pub fn is_noerror(&self) -> bool {
|
||||
matches!(self, Self::NOERROR)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for DigStatus {
|
||||
type Err = Error;
|
||||
|
||||
fn from_str(input: &str) -> Result<Self> {
|
||||
let status = match input {
|
||||
"NXDOMAIN" => Self::NXDOMAIN,
|
||||
"NOERROR" => Self::NOERROR,
|
||||
"REFUSED" => Self::REFUSED,
|
||||
_ => return Err(format!("unknown status: {input}").into()),
|
||||
};
|
||||
|
||||
Ok(status)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dig_nxdomain() -> Result<()> {
|
||||
// $ dig nonexistent.domain.
|
||||
let input = "
|
||||
; <<>> DiG 9.18.18-0ubuntu0.22.04.1-Ubuntu <<>> nonexistent.domain.
|
||||
;; global options: +cmd
|
||||
;; Got answer:
|
||||
;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 45583
|
||||
;; flags: qr rd ra; QUERY: 1, ANSWER: 0, AUTHORITY: 0, ADDITIONAL: 1
|
||||
|
||||
;; OPT PSEUDOSECTION:
|
||||
; EDNS: version: 0, flags:; udp: 1232
|
||||
;; QUESTION SECTION:
|
||||
;nonexistent.domain. IN A
|
||||
|
||||
;; Query time: 3 msec
|
||||
;; SERVER: 192.168.1.1#53(192.168.1.1) (UDP)
|
||||
;; WHEN: Tue Feb 06 15:00:12 UTC 2024
|
||||
;; MSG SIZE rcvd: 47
|
||||
";
|
||||
|
||||
let output: DigOutput = input.parse()?;
|
||||
|
||||
assert_eq!(DigStatus::NXDOMAIN, output.status);
|
||||
assert_eq!(
|
||||
DigFlags {
|
||||
qr: true,
|
||||
recursion_desired: true,
|
||||
recursion_available: true,
|
||||
..DigFlags::default()
|
||||
},
|
||||
output.flags
|
||||
);
|
||||
assert!(output.answer.is_empty());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
257
src/container.rs
Normal file
257
src/container.rs
Normal file
|
@ -0,0 +1,257 @@
|
|||
use core::str;
|
||||
use std::fs;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::path::Path;
|
||||
use std::process::{self, Child, ExitStatus};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::{atomic, Once};
|
||||
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
pub struct Container {
|
||||
name: String,
|
||||
id: String,
|
||||
// TODO probably also want the IPv6 address
|
||||
ipv4_addr: Ipv4Addr,
|
||||
}
|
||||
|
||||
impl Container {
|
||||
/// Starts the container in a "parked" state
|
||||
pub fn run() -> Result<Self> {
|
||||
static ONCE: Once = Once::new();
|
||||
static COUNT: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
// TODO configurable: hickory; bind
|
||||
let binary = "unbound";
|
||||
let image_tag = format!("dnssec-tests-{binary}");
|
||||
|
||||
let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let dockerfile_path = manifest_dir
|
||||
.join("docker")
|
||||
.join(format!("{binary}.Dockerfile"));
|
||||
let docker_dir_path = manifest_dir.join("docker");
|
||||
|
||||
let mut command = Command::new("docker");
|
||||
command
|
||||
.args(["build", "-t"])
|
||||
.arg(&image_tag)
|
||||
.arg("-f")
|
||||
.arg(dockerfile_path)
|
||||
.arg(docker_dir_path);
|
||||
|
||||
ONCE.call_once(|| {
|
||||
let status = command.status().unwrap();
|
||||
assert!(status.success());
|
||||
});
|
||||
|
||||
let mut command = Command::new("docker");
|
||||
let pid = process::id();
|
||||
let count = COUNT.fetch_add(1, atomic::Ordering::Relaxed);
|
||||
let name = format!("{binary}-{pid}-{count}");
|
||||
command
|
||||
.args(["run", "--rm", "--detach", "--name", &name])
|
||||
.arg("-it")
|
||||
.arg(image_tag)
|
||||
.args(["sleep", "infinity"]);
|
||||
|
||||
let output: Output = checked_output(&mut command)?.try_into()?;
|
||||
let id = output.stdout;
|
||||
|
||||
let ipv4_addr = get_ipv4_addr(&id)?;
|
||||
|
||||
Ok(Self {
|
||||
id,
|
||||
name,
|
||||
ipv4_addr,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn cp(&self, path_in_container: &str, file_contents: &str) -> Result<()> {
|
||||
const CHMOD_RW_EVERYONE: &str = "666";
|
||||
|
||||
let mut temp_file = NamedTempFile::new()?;
|
||||
fs::write(&mut temp_file, file_contents)?;
|
||||
|
||||
let src_path = temp_file.path().display().to_string();
|
||||
let dest_path = format!("{}:{path_in_container}", self.id);
|
||||
|
||||
let mut command = Command::new("docker");
|
||||
command.args(["cp", &src_path, &dest_path]);
|
||||
checked_output(&mut command)?;
|
||||
|
||||
self.status_ok(&["chmod", CHMOD_RW_EVERYONE, path_in_container])?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Similar to `std::process::Command::output` but runs `command_and_args` in the container
|
||||
pub fn output(&self, command_and_args: &[&str]) -> Result<Output> {
|
||||
let mut command = Command::new("docker");
|
||||
command
|
||||
.args(["exec", "-t", &self.id])
|
||||
.args(command_and_args);
|
||||
|
||||
command.output()?.try_into()
|
||||
}
|
||||
|
||||
/// Similar to `Self::output` but checks `command_and_args` ran successfully and only
|
||||
/// returns the stdout
|
||||
pub fn stdout(&self, command_and_args: &[&str]) -> Result<String> {
|
||||
let output = self.output(command_and_args)?;
|
||||
|
||||
if output.status.success() {
|
||||
Ok(output.stdout)
|
||||
} else {
|
||||
Err(format!("[{}] `{command_and_args:?}` failed", self.name).into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Similar to `std::process::Command::status` but runs `command_and_args` in the container
|
||||
pub fn status(&self, command_and_args: &[&str]) -> Result<ExitStatus> {
|
||||
let mut command = Command::new("docker");
|
||||
command
|
||||
.args(["exec", "-t", &self.id])
|
||||
.args(command_and_args);
|
||||
|
||||
Ok(command.status()?)
|
||||
}
|
||||
|
||||
/// Like `Self::status` but checks that `command_and_args` executed successfully
|
||||
pub fn status_ok(&self, command_and_args: &[&str]) -> Result<()> {
|
||||
let status = self.status(command_and_args)?;
|
||||
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("[{}] `{command_and_args:?}` failed", self.name).into())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spawn(&self, cmd: &[&str]) -> Result<Child> {
|
||||
let mut command = Command::new("docker");
|
||||
command.args(["exec", "-t", &self.id]).args(cmd);
|
||||
|
||||
Ok(command.spawn()?)
|
||||
}
|
||||
|
||||
pub fn ipv4_addr(&self) -> Ipv4Addr {
|
||||
self.ipv4_addr
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Output {
|
||||
pub status: ExitStatus,
|
||||
pub stderr: String,
|
||||
pub stdout: String,
|
||||
}
|
||||
|
||||
impl TryFrom<process::Output> for Output {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(output: process::Output) -> Result<Self> {
|
||||
let mut stderr = String::from_utf8(output.stderr)?;
|
||||
while stderr.ends_with(|c| matches!(c, '\n' | '\r')) {
|
||||
stderr.pop();
|
||||
}
|
||||
|
||||
let mut stdout = String::from_utf8(output.stdout)?;
|
||||
while stdout.ends_with(|c| matches!(c, '\n' | '\r')) {
|
||||
stdout.pop();
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
status: output.status,
|
||||
stderr,
|
||||
stdout,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn checked_output(command: &mut Command) -> Result<process::Output> {
|
||||
let output = command.output()?;
|
||||
if output.status.success() {
|
||||
Ok(output)
|
||||
} else {
|
||||
Err(format!("`{command:?}` failed").into())
|
||||
}
|
||||
}
|
||||
|
||||
fn get_ipv4_addr(container_id: &str) -> Result<Ipv4Addr> {
|
||||
let mut command = Command::new("docker");
|
||||
command
|
||||
.args([
|
||||
"inspect",
|
||||
"-f",
|
||||
"{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}",
|
||||
])
|
||||
.arg(container_id);
|
||||
|
||||
let output = command.output()?;
|
||||
if !output.status.success() {
|
||||
return Err(format!("`{command:?}` failed").into());
|
||||
}
|
||||
|
||||
let ipv4_addr = str::from_utf8(&output.stdout)?.trim().to_string();
|
||||
|
||||
Ok(ipv4_addr.parse()?)
|
||||
}
|
||||
|
||||
// ensure the container gets deleted
|
||||
impl Drop for Container {
|
||||
fn drop(&mut self) {
|
||||
// running this to completion would block the current thread for several seconds so just
|
||||
// fire and forget
|
||||
let _ = Command::new("docker")
|
||||
.args(["rm", "-f", &self.id])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn run_works() -> Result<()> {
|
||||
let container = Container::run()?;
|
||||
|
||||
let output = container.output(&["true"])?;
|
||||
assert!(output.status.success());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipv4_addr_works() -> Result<()> {
|
||||
let container = Container::run()?;
|
||||
let ipv4_addr = container.ipv4_addr();
|
||||
|
||||
let output = container.output(&["ping", "-c1", &format!("{ipv4_addr}")])?;
|
||||
assert!(output.status.success());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cp_works() -> Result<()> {
|
||||
let container = Container::run()?;
|
||||
|
||||
let path = "/tmp/somefile";
|
||||
let contents = "hello";
|
||||
container.cp(path, contents)?;
|
||||
|
||||
let output = container.output(&["cat", path])?;
|
||||
dbg!(&output);
|
||||
|
||||
assert!(output.status.success());
|
||||
assert_eq!(contents, output.stdout);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
74
src/fqdn.rs
Normal file
74
src/fqdn.rs
Normal file
|
@ -0,0 +1,74 @@
|
|||
use core::fmt;
|
||||
use core::str::FromStr;
|
||||
use std::borrow::Cow;
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct FQDN<'a> {
|
||||
inner: Cow<'a, str>,
|
||||
}
|
||||
|
||||
// TODO likely needs further validation
|
||||
#[allow(non_snake_case)]
|
||||
pub fn FQDN<'a>(input: impl Into<Cow<'a, str>>) -> Result<FQDN<'a>> {
|
||||
let input = input.into();
|
||||
if !input.ends_with('.') {
|
||||
return Err("FQDN must end with a `.`".into());
|
||||
}
|
||||
|
||||
if input != "." && input.starts_with('.') {
|
||||
return Err("non-root FQDN cannot start with a `.`".into());
|
||||
}
|
||||
|
||||
Ok(FQDN { inner: input })
|
||||
}
|
||||
|
||||
impl<'a> FQDN<'a> {
|
||||
pub const ROOT: FQDN<'static> = FQDN {
|
||||
inner: Cow::Borrowed("."),
|
||||
};
|
||||
|
||||
pub const COM: FQDN<'static> = FQDN {
|
||||
inner: Cow::Borrowed("com."),
|
||||
};
|
||||
|
||||
pub fn is_root(&self) -> bool {
|
||||
self.inner == "."
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.inner
|
||||
}
|
||||
|
||||
pub fn into_owned(self) -> FQDN<'static> {
|
||||
let owned = match self.inner {
|
||||
Cow::Borrowed(borrowed) => borrowed.to_string(),
|
||||
Cow::Owned(owned) => owned,
|
||||
};
|
||||
|
||||
FQDN {
|
||||
inner: Cow::Owned(owned),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for FQDN<'static> {
|
||||
type Err = Error;
|
||||
|
||||
fn from_str(input: &str) -> Result<Self> {
|
||||
Ok(FQDN(input)?.into_owned())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for FQDN<'_> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
fmt::Display::fmt(self, f)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for FQDN<'_> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.inner)
|
||||
}
|
||||
}
|
23
src/lib.rs
23
src/lib.rs
|
@ -1,14 +1,13 @@
|
|||
pub fn add(left: usize, right: usize) -> usize {
|
||||
left + right
|
||||
}
|
||||
pub use crate::fqdn::FQDN;
|
||||
pub use crate::recursive_resolver::RecursiveResolver;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
pub type Error = Box<dyn std::error::Error>;
|
||||
pub type Result<T> = core::result::Result<T, Error>;
|
||||
|
||||
#[test]
|
||||
fn it_works() {
|
||||
let result = add(2, 2);
|
||||
assert_eq!(result, 4);
|
||||
}
|
||||
}
|
||||
pub mod client;
|
||||
mod container;
|
||||
mod fqdn;
|
||||
pub mod name_server;
|
||||
pub mod record;
|
||||
mod recursive_resolver;
|
||||
pub mod zone_file;
|
||||
|
|
355
src/name_server.rs
Normal file
355
src/name_server.rs
Normal file
|
@ -0,0 +1,355 @@
|
|||
use core::sync::atomic::{self, AtomicUsize};
|
||||
use std::net::Ipv4Addr;
|
||||
use std::process::Child;
|
||||
|
||||
use crate::container::Container;
|
||||
use crate::zone_file::{self, SoaSettings, ZoneFile, DNSKEY, DS};
|
||||
use crate::{Result, FQDN};
|
||||
|
||||
pub struct NameServer<'a, State> {
|
||||
container: Container,
|
||||
zone_file: ZoneFile<'a>,
|
||||
state: State,
|
||||
}
|
||||
|
||||
impl<'a> NameServer<'a, Stopped> {
|
||||
/// Spins up a primary name server that has authority over the given `zone`
|
||||
///
|
||||
/// The initial state of the server is the "Stopped" state where it won't answer any query.
|
||||
///
|
||||
/// The FQDN of the name server will have the form `primary{count}.nameservers.com.` where
|
||||
/// `{count}` is a (process-wide) unique, monotonically increasing integer
|
||||
///
|
||||
/// The zone file will contain these records
|
||||
///
|
||||
/// - one SOA record, with the primary name server field set to this name server's FQDN
|
||||
/// - one NS record, with this name server's FQDN set as the only available name server for
|
||||
/// the zone
|
||||
pub fn new(zone: FQDN<'a>) -> Result<Self> {
|
||||
let ns_count = ns_count();
|
||||
let nameserver = primary_ns(ns_count);
|
||||
|
||||
let soa = zone_file::SOA {
|
||||
zone: zone.clone(),
|
||||
nameserver: nameserver.clone(),
|
||||
admin: admin_ns(ns_count),
|
||||
settings: SoaSettings::default(),
|
||||
};
|
||||
let mut zone_file = ZoneFile::new(zone.clone(), soa);
|
||||
|
||||
zone_file.entry(zone_file::NS {
|
||||
zone,
|
||||
nameserver: nameserver.clone(),
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
container: Container::run()?,
|
||||
zone_file,
|
||||
state: Stopped,
|
||||
})
|
||||
}
|
||||
|
||||
/// Adds a NS + A record pair to the zone file
|
||||
pub fn referral(
|
||||
&mut self,
|
||||
zone: FQDN<'a>,
|
||||
nameserver: FQDN<'a>,
|
||||
ipv4_addr: Ipv4Addr,
|
||||
) -> &mut Self {
|
||||
self.zone_file.referral(zone, nameserver, ipv4_addr);
|
||||
self
|
||||
}
|
||||
|
||||
/// Adds an A record pair to the zone file
|
||||
pub fn a(&mut self, fqdn: FQDN<'a>, ipv4_addr: Ipv4Addr) -> &mut Self {
|
||||
self.zone_file.entry(zone_file::A { fqdn, ipv4_addr });
|
||||
self
|
||||
}
|
||||
|
||||
/// Adds a DS record to the zone file
|
||||
pub fn ds(&mut self, ds: DS) -> &mut Self {
|
||||
self.zone_file.entry(ds);
|
||||
self
|
||||
}
|
||||
|
||||
/// Freezes and signs the name server's zone file
|
||||
pub fn sign(self) -> Result<NameServer<'a, Signed>> {
|
||||
// TODO do we want to make these settings configurable?
|
||||
const ZSK_BITS: usize = 1024;
|
||||
const KSK_BITS: usize = 2048;
|
||||
const ALGORITHM: &str = "RSASHA1-NSEC3-SHA1";
|
||||
|
||||
let Self {
|
||||
container,
|
||||
zone_file,
|
||||
state: _,
|
||||
} = self;
|
||||
|
||||
container.status_ok(&["mkdir", "-p", ZONES_DIR])?;
|
||||
container.cp("/etc/nsd/zones/main.zone", &zone_file.to_string())?;
|
||||
|
||||
let zone = &zone_file.origin;
|
||||
|
||||
let zsk_keygen =
|
||||
format!("cd {ZONES_DIR} && ldns-keygen -a {ALGORITHM} -b {ZSK_BITS} {zone}");
|
||||
let zsk_filename = container.stdout(&["sh", "-c", &zsk_keygen])?;
|
||||
let zsk_path = format!("{ZONES_DIR}/{zsk_filename}.key");
|
||||
let zsk: DNSKEY = container.stdout(&["cat", &zsk_path])?.parse()?;
|
||||
|
||||
let ksk_keygen =
|
||||
format!("cd {ZONES_DIR} && ldns-keygen -k -a {ALGORITHM} -b {KSK_BITS} {zone}");
|
||||
let ksk_filename = container.stdout(&["sh", "-c", &ksk_keygen])?;
|
||||
let ksk_path = format!("{ZONES_DIR}/{ksk_filename}.key");
|
||||
let ksk: DNSKEY = container.stdout(&["cat", &ksk_path])?.parse()?;
|
||||
|
||||
// -n = use NSEC3 instead of NSEC
|
||||
// -p = set the opt-out flag on all nsec3 rrs
|
||||
let signzone = format!(
|
||||
"cd {ZONES_DIR} && ldns-signzone -n -p {ZONE_FILENAME} {zsk_filename} {ksk_filename}"
|
||||
);
|
||||
container.status_ok(&["sh", "-c", &signzone])?;
|
||||
|
||||
// TODO do we want to make the hashing algorithm configurable?
|
||||
// -2 = use SHA256 for the DS hash
|
||||
let key2ds = format!("cd {ZONES_DIR} && ldns-key2ds -n -2 {ZONE_FILENAME}.signed");
|
||||
let ds: DS = container.stdout(&["sh", "-c", &key2ds])?.parse()?;
|
||||
|
||||
// we have an in-memory representation of the zone file so we just delete the on-disk version
|
||||
let zone_file_path = zone_file_path();
|
||||
container.status_ok(&["mv", &format!("{zone_file_path}.signed"), &zone_file_path])?;
|
||||
|
||||
let signed_zone_file = container.stdout(&["cat", &zone_file_path])?;
|
||||
|
||||
Ok(NameServer {
|
||||
container,
|
||||
zone_file,
|
||||
state: Signed {
|
||||
ds,
|
||||
ksk,
|
||||
signed_zone_file,
|
||||
zsk,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Moves the server to the "Start" state where it can answer client queries
|
||||
pub fn start(self) -> Result<NameServer<'a, Running>> {
|
||||
let Self {
|
||||
container,
|
||||
zone_file,
|
||||
state: _,
|
||||
} = self;
|
||||
|
||||
// for PID file
|
||||
container.status_ok(&["mkdir", "-p", "/run/nsd/"])?;
|
||||
|
||||
container.cp("/etc/nsd/nsd.conf", &nsd_conf(&zone_file.origin))?;
|
||||
|
||||
container.status_ok(&["mkdir", "-p", ZONES_DIR])?;
|
||||
container.cp(&zone_file_path(), &zone_file.to_string())?;
|
||||
|
||||
let child = container.spawn(&["nsd", "-d"])?;
|
||||
|
||||
Ok(NameServer {
|
||||
container,
|
||||
zone_file,
|
||||
state: Running { child },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const ZONES_DIR: &str = "/etc/nsd/zones";
|
||||
const ZONE_FILENAME: &str = "main.zone";
|
||||
|
||||
fn zone_file_path() -> String {
|
||||
format!("{ZONES_DIR}/{ZONE_FILENAME}")
|
||||
}
|
||||
|
||||
fn ns_count() -> usize {
|
||||
static COUNT: AtomicUsize = AtomicUsize::new(0);
|
||||
COUNT.fetch_add(1, atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
impl<'a> NameServer<'a, Signed> {
|
||||
/// Moves the server to the "Start" state where it can answer client queries
|
||||
pub fn start(self) -> Result<NameServer<'a, Running>> {
|
||||
let Self {
|
||||
container,
|
||||
zone_file,
|
||||
state: _,
|
||||
} = self;
|
||||
|
||||
// for PID file
|
||||
container.status_ok(&["mkdir", "-p", "/run/nsd/"])?;
|
||||
|
||||
container.cp("/etc/nsd/nsd.conf", &nsd_conf(&zone_file.origin))?;
|
||||
|
||||
let child = container.spawn(&["nsd", "-d"])?;
|
||||
|
||||
Ok(NameServer {
|
||||
container,
|
||||
zone_file,
|
||||
state: Running { child },
|
||||
})
|
||||
}
|
||||
|
||||
pub fn key_signing_key(&self) -> &DNSKEY {
|
||||
&self.state.ksk
|
||||
}
|
||||
|
||||
pub fn zone_signing_key(&self) -> &DNSKEY {
|
||||
&self.state.zsk
|
||||
}
|
||||
|
||||
pub fn signed_zone_file(&self) -> &str {
|
||||
&self.state.signed_zone_file
|
||||
}
|
||||
|
||||
pub fn ds(&self) -> &DS {
|
||||
&self.state.ds
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, S> NameServer<'a, S> {
|
||||
pub fn ipv4_addr(&self) -> Ipv4Addr {
|
||||
self.container.ipv4_addr()
|
||||
}
|
||||
|
||||
/// Zone file BEFORE signing
|
||||
pub fn zone_file(&self) -> &ZoneFile<'a> {
|
||||
&self.zone_file
|
||||
}
|
||||
|
||||
pub fn zone(&self) -> &FQDN<'a> {
|
||||
&self.zone_file.origin
|
||||
}
|
||||
|
||||
pub fn fqdn(&self) -> &FQDN<'a> {
|
||||
&self.zone_file.soa.nameserver
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Stopped;
|
||||
|
||||
pub struct Signed {
|
||||
ds: DS,
|
||||
zsk: DNSKEY,
|
||||
ksk: DNSKEY,
|
||||
signed_zone_file: String,
|
||||
}
|
||||
|
||||
pub struct Running {
|
||||
child: Child,
|
||||
}
|
||||
|
||||
impl Drop for Running {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
}
|
||||
}
|
||||
|
||||
fn primary_ns(ns_count: usize) -> FQDN<'static> {
|
||||
FQDN(format!("primary{ns_count}.nameservers.com.")).unwrap()
|
||||
}
|
||||
|
||||
fn admin_ns(ns_count: usize) -> FQDN<'static> {
|
||||
FQDN(format!("admin{ns_count}.nameservers.com.")).unwrap()
|
||||
}
|
||||
|
||||
fn nsd_conf(fqdn: &FQDN) -> String {
|
||||
minijinja::render!(
|
||||
include_str!("templates/nsd.conf.jinja"),
|
||||
fqdn => fqdn.as_str()
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::client::{Client, Dnssec, Recurse};
|
||||
use crate::record::RecordType;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn simplest() -> Result<()> {
|
||||
let tld_ns = NameServer::new(FQDN::COM)?.start()?;
|
||||
let ip_addr = tld_ns.ipv4_addr();
|
||||
|
||||
let client = Client::new()?;
|
||||
let output = client.dig(
|
||||
Recurse::No,
|
||||
Dnssec::No,
|
||||
ip_addr,
|
||||
RecordType::SOA,
|
||||
&FQDN::COM,
|
||||
)?;
|
||||
|
||||
assert!(output.status.is_noerror());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_referral() -> Result<()> {
|
||||
let expected_ip_addr = Ipv4Addr::new(172, 17, 200, 1);
|
||||
let mut root_ns = NameServer::new(FQDN::ROOT)?;
|
||||
root_ns.referral(
|
||||
FQDN::COM,
|
||||
FQDN("primary.tld-server.com.")?,
|
||||
expected_ip_addr,
|
||||
);
|
||||
let root_ns = root_ns.start()?;
|
||||
|
||||
eprintln!("root.zone:\n{}", root_ns.zone_file());
|
||||
|
||||
let ipv4_addr = root_ns.ipv4_addr();
|
||||
|
||||
let client = Client::new()?;
|
||||
let output = client.dig(
|
||||
Recurse::No,
|
||||
Dnssec::No,
|
||||
ipv4_addr,
|
||||
RecordType::NS,
|
||||
&FQDN::COM,
|
||||
)?;
|
||||
|
||||
assert!(output.status.is_noerror());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed() -> Result<()> {
|
||||
let ns = NameServer::new(FQDN::ROOT)?.sign()?;
|
||||
|
||||
eprintln!("KSK:\n{}", ns.key_signing_key());
|
||||
eprintln!("ZSK:\n{}", ns.zone_signing_key());
|
||||
eprintln!("root.zone.signed:\n{}", ns.signed_zone_file());
|
||||
|
||||
let tld_ns = ns.start()?;
|
||||
|
||||
let ns_addr = tld_ns.ipv4_addr();
|
||||
|
||||
let client = Client::new()?;
|
||||
let output = client.dig(
|
||||
Recurse::No,
|
||||
Dnssec::Yes,
|
||||
ns_addr,
|
||||
RecordType::SOA,
|
||||
&FQDN::ROOT,
|
||||
)?;
|
||||
|
||||
assert!(output.status.is_noerror());
|
||||
|
||||
let [soa, rrsig] = output
|
||||
.answer
|
||||
.try_into()
|
||||
.expect("two records in answer section");
|
||||
|
||||
assert!(soa.is_soa());
|
||||
let rrsig = rrsig.try_into_rrsig().unwrap();
|
||||
assert_eq!(RecordType::SOA, rrsig.type_covered);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
296
src/record.rs
Normal file
296
src/record.rs
Normal file
|
@ -0,0 +1,296 @@
|
|||
//! Text representation of DNS records
|
||||
|
||||
use core::array;
|
||||
use core::result::Result as CoreResult;
|
||||
use core::str::FromStr;
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
use crate::{Error, Result, FQDN};
|
||||
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum RecordType {
|
||||
A,
|
||||
NS,
|
||||
SOA,
|
||||
}
|
||||
|
||||
impl RecordType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
RecordType::A => "A",
|
||||
RecordType::SOA => "SOA",
|
||||
RecordType::NS => "NS",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for RecordType {
|
||||
type Err = Error;
|
||||
|
||||
fn from_str(input: &str) -> CoreResult<Self, Self::Err> {
|
||||
let record_type = match input {
|
||||
"A" => Self::A,
|
||||
"SOA" => Self::SOA,
|
||||
"NS" => Self::NS,
|
||||
_ => return Err(format!("unknown record type: {input}").into()),
|
||||
};
|
||||
|
||||
Ok(record_type)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
pub enum Record {
|
||||
A(A),
|
||||
RRSIG(RRSIG),
|
||||
SOA(SOA),
|
||||
}
|
||||
|
||||
impl Record {
|
||||
pub fn try_into_a(self) -> CoreResult<A, Self> {
|
||||
if let Self::A(v) = self {
|
||||
Ok(v)
|
||||
} else {
|
||||
Err(self)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn try_into_rrsig(self) -> CoreResult<RRSIG, Self> {
|
||||
if let Self::RRSIG(v) = self {
|
||||
Ok(v)
|
||||
} else {
|
||||
Err(self)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_soa(&self) -> bool {
|
||||
matches!(self, Self::SOA(..))
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Record {
|
||||
type Err = Error;
|
||||
|
||||
fn from_str(input: &str) -> Result<Self> {
|
||||
let record_type = input
|
||||
.split_whitespace()
|
||||
.nth(3)
|
||||
.ok_or("record is missing the type column")?;
|
||||
|
||||
let record = match record_type {
|
||||
"A" => Record::A(input.parse()?),
|
||||
"NS" => todo!(),
|
||||
"RRSIG" => Record::RRSIG(input.parse()?),
|
||||
"SOA" => Record::SOA(input.parse()?),
|
||||
_ => return Err(format!("unknown record type: {record_type}").into()),
|
||||
};
|
||||
|
||||
Ok(record)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct A {
|
||||
pub fqdn: FQDN<'static>,
|
||||
pub ttl: u32,
|
||||
pub ipv4_addr: Ipv4Addr,
|
||||
}
|
||||
|
||||
impl FromStr for A {
|
||||
type Err = Error;
|
||||
|
||||
fn from_str(input: &str) -> Result<Self> {
|
||||
let mut columns = input.split_whitespace();
|
||||
|
||||
let [Some(fqdn), Some(ttl), Some(class), Some(record_type), Some(ipv4_addr), None] =
|
||||
array::from_fn(|_| columns.next())
|
||||
else {
|
||||
return Err("expected 5 columns".into());
|
||||
};
|
||||
|
||||
let expected = "A";
|
||||
if record_type != expected {
|
||||
return Err(
|
||||
format!("tried to parse `{record_type}` record as an {expected} record").into(),
|
||||
);
|
||||
}
|
||||
|
||||
if class != "IN" {
|
||||
return Err(format!("unknown class: {class}").into());
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
fqdn: fqdn.parse()?,
|
||||
ttl: ttl.parse()?,
|
||||
ipv4_addr: ipv4_addr.parse()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
#[derive(Debug)]
|
||||
pub struct RRSIG {
|
||||
pub fqdn: FQDN<'static>,
|
||||
pub ttl: u32,
|
||||
pub type_covered: RecordType,
|
||||
pub algorithm: u32,
|
||||
pub labels: u32,
|
||||
pub original_ttl: u32,
|
||||
pub signature_expiration: u64,
|
||||
pub signature_inception: u64,
|
||||
pub key_tag: u32,
|
||||
pub signer_name: FQDN<'static>,
|
||||
/// base64 encoded
|
||||
pub signature: String,
|
||||
}
|
||||
|
||||
impl FromStr for RRSIG {
|
||||
type Err = Error;
|
||||
|
||||
fn from_str(input: &str) -> CoreResult<Self, Self::Err> {
|
||||
let mut columns = input.split_whitespace();
|
||||
|
||||
let [Some(fqdn), Some(ttl), Some(class), Some(record_type), Some(type_covered), Some(algorithm), Some(labels), Some(original_ttl), Some(signature_expiration), Some(signature_inception), Some(key_tag), Some(signer_name)] =
|
||||
array::from_fn(|_| columns.next())
|
||||
else {
|
||||
return Err("expected at least 12 columns".into());
|
||||
};
|
||||
|
||||
let expected = "RRSIG";
|
||||
if record_type != expected {
|
||||
return Err(
|
||||
format!("tried to parse `{record_type}` record as a {expected} record").into(),
|
||||
);
|
||||
}
|
||||
|
||||
if class != "IN" {
|
||||
return Err(format!("unknown class: {class}").into());
|
||||
}
|
||||
|
||||
let mut signature = String::new();
|
||||
for column in columns {
|
||||
signature.push_str(column);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
fqdn: fqdn.parse()?,
|
||||
ttl: ttl.parse()?,
|
||||
type_covered: type_covered.parse()?,
|
||||
algorithm: algorithm.parse()?,
|
||||
labels: labels.parse()?,
|
||||
original_ttl: original_ttl.parse()?,
|
||||
signature_expiration: signature_expiration.parse()?,
|
||||
signature_inception: signature_inception.parse()?,
|
||||
key_tag: key_tag.parse()?,
|
||||
signer_name: signer_name.parse()?,
|
||||
signature,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
#[derive(Debug)]
|
||||
pub struct SOA {
|
||||
pub zone: FQDN<'static>,
|
||||
pub ttl: u32,
|
||||
pub nameserver: FQDN<'static>,
|
||||
pub admin: FQDN<'static>,
|
||||
pub serial: u32,
|
||||
pub refresh: u32,
|
||||
pub retry: u32,
|
||||
pub expire: u32,
|
||||
pub minimum: u32,
|
||||
}
|
||||
|
||||
impl FromStr for SOA {
|
||||
type Err = Error;
|
||||
|
||||
fn from_str(input: &str) -> Result<Self> {
|
||||
let mut columns = input.split_whitespace();
|
||||
|
||||
let [Some(zone), Some(ttl), Some(class), Some(record_type), Some(nameserver), Some(admin), Some(serial), Some(refresh), Some(retry), Some(expire), Some(minimum), None] =
|
||||
array::from_fn(|_| columns.next())
|
||||
else {
|
||||
return Err("expected 11 columns".into());
|
||||
};
|
||||
|
||||
if record_type != "SOA" {
|
||||
return Err(format!("tried to parse `{record_type}` record as a SOA record").into());
|
||||
}
|
||||
|
||||
if class != "IN" {
|
||||
return Err(format!("unknown class: {class}").into());
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
zone: zone.parse()?,
|
||||
ttl: ttl.parse()?,
|
||||
nameserver: nameserver.parse()?,
|
||||
admin: admin.parse()?,
|
||||
serial: serial.parse()?,
|
||||
refresh: refresh.parse()?,
|
||||
retry: retry.parse()?,
|
||||
expire: expire.parse()?,
|
||||
minimum: minimum.parse()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn can_parse_a_record() -> Result<()> {
|
||||
let input = "a.root-servers.net. 3600000 IN A 198.41.0.4";
|
||||
let a: A = input.parse()?;
|
||||
|
||||
assert_eq!("a.root-servers.net.", a.fqdn.as_str());
|
||||
assert_eq!(3600000, a.ttl);
|
||||
assert_eq!(Ipv4Addr::new(198, 41, 0, 4), a.ipv4_addr);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_parse_soa_record() -> Result<()> {
|
||||
let input = ". 15633 IN SOA a.root-servers.net. nstld.verisign-grs.com. 2024020501 1800 900 604800 86400";
|
||||
|
||||
let soa: SOA = input.parse()?;
|
||||
|
||||
assert_eq!(".", soa.zone.as_str());
|
||||
assert_eq!(15633, soa.ttl);
|
||||
assert_eq!("a.root-servers.net.", soa.nameserver.as_str());
|
||||
assert_eq!("nstld.verisign-grs.com.", soa.admin.as_str());
|
||||
assert_eq!(2024020501, soa.serial);
|
||||
assert_eq!(1800, soa.refresh);
|
||||
assert_eq!(900, soa.retry);
|
||||
assert_eq!(604800, soa.expire);
|
||||
assert_eq!(86400, soa.minimum);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_parse_rrsig_record() -> Result<()> {
|
||||
let input = ". 1800 IN RRSIG SOA 7 0 1800 20240306132701 20240207132701 11264 . wXpRU4elJPGYm2kgVVsIwGf1IkYJcQ3UE4mwmItWdxj0XWSWY07MO4Ll DMJgsE0u64Q/345Ck7+aQ904uLebwCvpFnsmkyCxk82XIAfHN9FiwzSy qoR/zZEvBONaej3vrvsqPwh8q/pvypLft9647HcFdwY0juzZsbrAaDAX 8WY=";
|
||||
|
||||
let rrsig: RRSIG = input.parse()?;
|
||||
|
||||
assert_eq!(FQDN::ROOT, rrsig.fqdn);
|
||||
assert_eq!(1800, rrsig.ttl);
|
||||
assert_eq!(RecordType::SOA, rrsig.type_covered);
|
||||
assert_eq!(7, rrsig.algorithm);
|
||||
assert_eq!(0, rrsig.labels);
|
||||
assert_eq!(20240306132701, rrsig.signature_expiration);
|
||||
assert_eq!(20240207132701, rrsig.signature_inception);
|
||||
assert_eq!(11264, rrsig.key_tag);
|
||||
assert_eq!(FQDN::ROOT, rrsig.signer_name);
|
||||
let expected = "wXpRU4elJPGYm2kgVVsIwGf1IkYJcQ3UE4mwmItWdxj0XWSWY07MO4LlDMJgsE0u64Q/345Ck7+aQ904uLebwCvpFnsmkyCxk82XIAfHN9FiwzSyqoR/zZEvBONaej3vrvsqPwh8q/pvypLft9647HcFdwY0juzZsbrAaDAX8WY=";
|
||||
assert_eq!(expected, rrsig.signature);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
236
src/recursive_resolver.rs
Normal file
236
src/recursive_resolver.rs
Normal file
|
@ -0,0 +1,236 @@
|
|||
use core::fmt::Write;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::process::Child;
|
||||
|
||||
use crate::container::Container;
|
||||
use crate::zone_file::{Root, DNSKEY};
|
||||
use crate::Result;
|
||||
|
||||
pub struct RecursiveResolver {
|
||||
container: Container,
|
||||
child: Child,
|
||||
}
|
||||
|
||||
impl RecursiveResolver {
|
||||
pub fn start(roots: &[Root], trust_anchors: &[DNSKEY]) -> Result<Self> {
|
||||
const TRUST_ANCHOR_FILE: &str = "/etc/trusted-key.key";
|
||||
|
||||
let container = Container::run()?;
|
||||
|
||||
let mut hints = String::new();
|
||||
for root in roots {
|
||||
writeln!(hints, "{root}").unwrap();
|
||||
}
|
||||
|
||||
container.cp("/etc/unbound/root.hints", &hints)?;
|
||||
|
||||
let use_dnssec = !trust_anchors.is_empty();
|
||||
container.cp("/etc/unbound/unbound.conf", &unbound_conf(use_dnssec))?;
|
||||
|
||||
if use_dnssec {
|
||||
let trust_anchor = trust_anchors.iter().fold(String::new(), |mut buf, ds| {
|
||||
writeln!(buf, "{ds}").expect("infallible");
|
||||
buf
|
||||
});
|
||||
|
||||
container.cp(TRUST_ANCHOR_FILE, &trust_anchor)?;
|
||||
}
|
||||
|
||||
let child = container.spawn(&["unbound", "-d"])?;
|
||||
|
||||
Ok(Self { child, container })
|
||||
}
|
||||
|
||||
pub fn ipv4_addr(&self) -> Ipv4Addr {
|
||||
self.container.ipv4_addr()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RecursiveResolver {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
}
|
||||
}
|
||||
|
||||
fn unbound_conf(use_dnssec: bool) -> String {
|
||||
minijinja::render!(include_str!("templates/unbound.conf.jinja"), use_dnssec => use_dnssec)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use crate::{
|
||||
client::{Client, Dnssec, Recurse},
|
||||
name_server::NameServer,
|
||||
record::RecordType,
|
||||
FQDN,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn can_resolve() -> Result<()> {
|
||||
let expected_ipv4_addr = Ipv4Addr::new(1, 2, 3, 4);
|
||||
let needle = FQDN("example.nameservers.com.")?;
|
||||
|
||||
let mut root_ns = NameServer::new(FQDN::ROOT)?;
|
||||
let mut com_ns = NameServer::new(FQDN::COM)?;
|
||||
|
||||
let mut nameservers_ns = NameServer::new(FQDN("nameservers.com.")?)?;
|
||||
nameservers_ns
|
||||
.a(root_ns.fqdn().clone(), root_ns.ipv4_addr())
|
||||
.a(com_ns.fqdn().clone(), com_ns.ipv4_addr())
|
||||
.a(needle.clone(), expected_ipv4_addr);
|
||||
let nameservers_ns = nameservers_ns.start()?;
|
||||
|
||||
eprintln!("nameservers.com.zone:\n{}", nameservers_ns.zone_file());
|
||||
|
||||
com_ns.referral(
|
||||
nameservers_ns.zone().clone(),
|
||||
nameservers_ns.fqdn().clone(),
|
||||
nameservers_ns.ipv4_addr(),
|
||||
);
|
||||
let com_ns = com_ns.start()?;
|
||||
|
||||
eprintln!("com.zone:\n{}", com_ns.zone_file());
|
||||
|
||||
root_ns.referral(FQDN::COM, com_ns.fqdn().clone(), com_ns.ipv4_addr());
|
||||
let root_ns = root_ns.start()?;
|
||||
|
||||
eprintln!("root.zone:\n{}", root_ns.zone_file());
|
||||
|
||||
let roots = &[Root::new(root_ns.fqdn().clone(), root_ns.ipv4_addr())];
|
||||
let resolver = RecursiveResolver::start(roots, &[])?;
|
||||
let resolver_ip_addr = resolver.ipv4_addr();
|
||||
|
||||
let client = Client::new()?;
|
||||
let output = client.dig(
|
||||
Recurse::Yes,
|
||||
Dnssec::No,
|
||||
resolver_ip_addr,
|
||||
RecordType::A,
|
||||
&needle,
|
||||
)?;
|
||||
|
||||
assert!(output.status.is_noerror());
|
||||
|
||||
let [answer] = output.answer.try_into().unwrap();
|
||||
let a = answer.try_into_a().unwrap();
|
||||
|
||||
assert_eq!(needle, a.fqdn);
|
||||
assert_eq!(expected_ipv4_addr, a.ipv4_addr);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// no DS records are involved; this is a single-link chain of trust
|
||||
#[test]
|
||||
fn can_validate_without_delegation() -> Result<()> {
|
||||
let mut ns = NameServer::new(FQDN::ROOT)?;
|
||||
ns.a(ns.fqdn().clone(), ns.ipv4_addr());
|
||||
let ns = ns.sign()?;
|
||||
|
||||
let root_ksk = ns.key_signing_key().clone();
|
||||
let root_zsk = ns.zone_signing_key().clone();
|
||||
|
||||
eprintln!("root.zone.signed:\n{}", ns.signed_zone_file());
|
||||
|
||||
let ns = ns.start()?;
|
||||
|
||||
eprintln!("root.zone:\n{}", ns.zone_file());
|
||||
|
||||
let roots = &[Root::new(ns.fqdn().clone(), ns.ipv4_addr())];
|
||||
|
||||
let trust_anchor = [root_ksk.clone(), root_zsk.clone()];
|
||||
let resolver = RecursiveResolver::start(roots, &trust_anchor)?;
|
||||
let resolver_addr = resolver.ipv4_addr();
|
||||
|
||||
let client = Client::new()?;
|
||||
let output = client.dig(
|
||||
Recurse::Yes,
|
||||
Dnssec::Yes,
|
||||
resolver_addr,
|
||||
RecordType::SOA,
|
||||
&FQDN::ROOT,
|
||||
)?;
|
||||
|
||||
assert!(output.status.is_noerror());
|
||||
assert!(output.flags.authenticated_data);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_validate_with_delegation() -> Result<()> {
|
||||
let expected_ipv4_addr = Ipv4Addr::new(1, 2, 3, 4);
|
||||
let needle = FQDN("example.nameservers.com.")?;
|
||||
|
||||
let mut root_ns = NameServer::new(FQDN::ROOT)?;
|
||||
let mut com_ns = NameServer::new(FQDN::COM)?;
|
||||
|
||||
let mut nameservers_ns = NameServer::new(FQDN("nameservers.com.")?)?;
|
||||
nameservers_ns
|
||||
.a(root_ns.fqdn().clone(), root_ns.ipv4_addr())
|
||||
.a(com_ns.fqdn().clone(), com_ns.ipv4_addr())
|
||||
.a(needle.clone(), expected_ipv4_addr);
|
||||
let nameservers_ns = nameservers_ns.sign()?;
|
||||
let nameservers_ds = nameservers_ns.ds().clone();
|
||||
let nameservers_ns = nameservers_ns.start()?;
|
||||
|
||||
eprintln!("nameservers.com.zone:\n{}", nameservers_ns.zone_file());
|
||||
|
||||
com_ns
|
||||
.referral(
|
||||
nameservers_ns.zone().clone(),
|
||||
nameservers_ns.fqdn().clone(),
|
||||
nameservers_ns.ipv4_addr(),
|
||||
)
|
||||
.ds(nameservers_ds);
|
||||
let com_ns = com_ns.sign()?;
|
||||
let com_ds = com_ns.ds().clone();
|
||||
let com_ns = com_ns.start()?;
|
||||
|
||||
eprintln!("com.zone:\n{}", com_ns.zone_file());
|
||||
|
||||
root_ns
|
||||
.referral(FQDN::COM, com_ns.fqdn().clone(), com_ns.ipv4_addr())
|
||||
.ds(com_ds);
|
||||
let root_ns = root_ns.sign()?;
|
||||
let root_ksk = root_ns.key_signing_key().clone();
|
||||
let root_zsk = root_ns.zone_signing_key().clone();
|
||||
|
||||
eprintln!("root.zone.signed:\n{}", root_ns.signed_zone_file());
|
||||
|
||||
let root_ns = root_ns.start()?;
|
||||
|
||||
eprintln!("root.zone:\n{}", root_ns.zone_file());
|
||||
|
||||
let roots = &[Root::new(root_ns.fqdn().clone(), root_ns.ipv4_addr())];
|
||||
|
||||
let resolver = RecursiveResolver::start(roots, &[root_ksk.clone(), root_zsk.clone()])?;
|
||||
let resolver_ip_addr = resolver.ipv4_addr();
|
||||
|
||||
let client = Client::new()?;
|
||||
let output = client.dig(
|
||||
Recurse::Yes,
|
||||
Dnssec::Yes,
|
||||
resolver_ip_addr,
|
||||
RecordType::A,
|
||||
&needle,
|
||||
)?;
|
||||
|
||||
drop(resolver);
|
||||
|
||||
assert!(output.status.is_noerror());
|
||||
|
||||
assert!(output.flags.authenticated_data);
|
||||
|
||||
let [a, _rrsig] = output.answer.try_into().unwrap();
|
||||
let a = a.try_into_a().unwrap();
|
||||
|
||||
assert_eq!(needle, a.fqdn);
|
||||
assert_eq!(expected_ipv4_addr, a.ipv4_addr);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
6
src/templates/nsd.conf.jinja
Normal file
6
src/templates/nsd.conf.jinja
Normal file
|
@ -0,0 +1,6 @@
|
|||
remote-control:
|
||||
control-enable: no
|
||||
|
||||
zone:
|
||||
name: {{ fqdn }}
|
||||
zonefile: /etc/nsd/zones/main.zone
|
|
@ -4,6 +4,9 @@ server:
|
|||
interface: 0.0.0.0
|
||||
access-control: 172.17.0.0/16 allow
|
||||
root-hints: /etc/unbound/root.hints
|
||||
{% if use_dnssec %}
|
||||
trust-anchor-file: /etc/trusted-key.key
|
||||
{% endif %}
|
||||
|
||||
remote-control:
|
||||
control-enable: no
|
493
src/zone_file.rs
Normal file
493
src/zone_file.rs
Normal file
|
@ -0,0 +1,493 @@
|
|||
//! BIND-style zone file
|
||||
//!
|
||||
//! Note that
|
||||
//! - the `@` syntax is not used to avoid relying on the order of the entries
|
||||
//! - relative domain names are not used; all domain names must be in fully-qualified form
|
||||
|
||||
use core::{array, fmt};
|
||||
use std::net::Ipv4Addr;
|
||||
use std::str::FromStr;
|
||||
|
||||
use crate::{Error, FQDN};
|
||||
|
||||
pub struct ZoneFile<'a> {
|
||||
pub origin: FQDN<'a>,
|
||||
pub ttl: u32,
|
||||
pub soa: SOA<'a>,
|
||||
pub entries: Vec<Entry<'a>>,
|
||||
}
|
||||
|
||||
impl<'a> ZoneFile<'a> {
|
||||
/// Convenience constructor that uses "reasonable" defaults
|
||||
pub fn new(origin: FQDN<'a>, soa: SOA<'a>) -> Self {
|
||||
Self {
|
||||
origin,
|
||||
ttl: 1800,
|
||||
soa,
|
||||
entries: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends an entry
|
||||
pub fn entry(&mut self, entry: impl Into<Entry<'a>>) {
|
||||
self.entries.push(entry.into())
|
||||
}
|
||||
|
||||
/// Appends a NS + A entry pair
|
||||
pub fn referral(&mut self, zone: FQDN<'a>, nameserver: FQDN<'a>, ipv4_addr: Ipv4Addr) {
|
||||
self.entry(NS {
|
||||
zone: zone.clone(),
|
||||
nameserver: nameserver.clone(),
|
||||
});
|
||||
self.entry(A {
|
||||
fqdn: nameserver,
|
||||
ipv4_addr,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ZoneFile<'_> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let Self {
|
||||
origin,
|
||||
ttl,
|
||||
soa,
|
||||
entries,
|
||||
} = self;
|
||||
|
||||
writeln!(f, "$ORIGIN {origin}")?;
|
||||
writeln!(f, "$TTL {ttl}")?;
|
||||
writeln!(f, "{soa}")?;
|
||||
|
||||
for entry in entries {
|
||||
writeln!(f, "{entry}")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Root<'a> {
|
||||
pub ipv4_addr: Ipv4Addr,
|
||||
pub ns: FQDN<'a>,
|
||||
pub ttl: u32,
|
||||
}
|
||||
|
||||
impl<'a> Root<'a> {
|
||||
/// Convenience constructor that uses "reasonable" defaults
|
||||
pub fn new(ns: FQDN<'a>, ipv4_addr: Ipv4Addr) -> Self {
|
||||
Self {
|
||||
ipv4_addr,
|
||||
ns,
|
||||
ttl: 3600000, // 1000 hours
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Root<'_> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let Self { ipv4_addr, ns, ttl } = self;
|
||||
|
||||
writeln!(f, ".\t{ttl}\tNS\t{ns}")?;
|
||||
write!(f, "{ns}\t{ttl}\tA\t{ipv4_addr}")
|
||||
}
|
||||
}
|
||||
|
||||
pub enum Entry<'a> {
|
||||
A(A<'a>),
|
||||
DNSKEY(DNSKEY),
|
||||
DS(DS),
|
||||
NS(NS<'a>),
|
||||
}
|
||||
|
||||
impl<'a> From<DS> for Entry<'a> {
|
||||
fn from(v: DS) -> Self {
|
||||
Self::DS(v)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<A<'a>> for Entry<'a> {
|
||||
fn from(v: A<'a>) -> Self {
|
||||
Self::A(v)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<NS<'a>> for Entry<'a> {
|
||||
fn from(v: NS<'a>) -> Self {
|
||||
Self::NS(v)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Entry<'_> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Entry::A(a) => a.fmt(f),
|
||||
Entry::DNSKEY(dnskey) => dnskey.fmt(f),
|
||||
Entry::DS(ds) => ds.fmt(f),
|
||||
Entry::NS(ns) => ns.fmt(f),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct A<'a> {
|
||||
pub fqdn: FQDN<'a>,
|
||||
pub ipv4_addr: Ipv4Addr,
|
||||
}
|
||||
|
||||
impl fmt::Display for A<'_> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let Self { fqdn, ipv4_addr } = self;
|
||||
|
||||
write!(f, "{fqdn}\tIN\tA\t{ipv4_addr}")
|
||||
}
|
||||
}
|
||||
|
||||
// integer types chosen based on bit sizes in section 2.1 of RFC4034
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DNSKEY {
|
||||
zone: FQDN<'static>,
|
||||
flags: u16,
|
||||
protocol: u8,
|
||||
algorithm: u8,
|
||||
public_key: String,
|
||||
|
||||
// extra information in `+multiline` format and `ldns-keygen`'s output
|
||||
bits: u16,
|
||||
key_tag: u16,
|
||||
}
|
||||
|
||||
impl DNSKEY {
|
||||
pub fn bits(&self) -> u16 {
|
||||
self.bits
|
||||
}
|
||||
|
||||
pub fn key_tag(&self) -> u16 {
|
||||
self.key_tag
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for DNSKEY {
|
||||
type Err = Error;
|
||||
|
||||
fn from_str(input: &str) -> Result<Self, Self::Err> {
|
||||
let (before, after) = input.split_once(';').ok_or("comment was not found")?;
|
||||
let mut columns = before.split_whitespace();
|
||||
|
||||
let [Some(zone), Some(class), Some(record_type), Some(flags), Some(protocol), Some(algorithm), Some(public_key), None] =
|
||||
array::from_fn(|_| columns.next())
|
||||
else {
|
||||
return Err("expected 7 columns".into());
|
||||
};
|
||||
|
||||
if record_type != "DNSKEY" {
|
||||
return Err(format!("tried to parse `{record_type}` record as a DNSKEY record").into());
|
||||
}
|
||||
|
||||
if class != "IN" {
|
||||
return Err(format!("unknown class: {class}").into());
|
||||
}
|
||||
|
||||
// {id = 24975 (zsk), size = 1024b}
|
||||
let error = "invalid comment syntax";
|
||||
let (id_expr, size_expr) = after.split_once(',').ok_or(error)?;
|
||||
|
||||
// {id = 24975 (zsk)
|
||||
let (id_lhs, id_rhs) = id_expr.split_once('=').ok_or(error)?;
|
||||
if id_lhs.trim() != "{id" {
|
||||
return Err(error.into());
|
||||
}
|
||||
|
||||
// 24975 (zsk)
|
||||
let (key_tag, _key_type) = id_rhs.trim().split_once(' ').ok_or(error)?;
|
||||
|
||||
// size = 1024b}
|
||||
let (size_lhs, size_rhs) = size_expr.split_once('=').ok_or(error)?;
|
||||
if size_lhs.trim() != "size" {
|
||||
return Err(error.into());
|
||||
}
|
||||
let bits = size_rhs.trim().strip_suffix("b}").ok_or(error)?.parse()?;
|
||||
|
||||
Ok(Self {
|
||||
zone: zone.parse()?,
|
||||
flags: flags.parse()?,
|
||||
protocol: protocol.parse()?,
|
||||
algorithm: algorithm.parse()?,
|
||||
public_key: public_key.to_string(),
|
||||
|
||||
key_tag: key_tag.parse()?,
|
||||
bits,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for DNSKEY {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let Self {
|
||||
zone,
|
||||
flags,
|
||||
protocol,
|
||||
algorithm,
|
||||
public_key,
|
||||
bits: _,
|
||||
key_tag: _,
|
||||
} = self;
|
||||
|
||||
write!(
|
||||
f,
|
||||
"{zone}\tIN\tDNSKEY\t{flags}\t{protocol}\t{algorithm}\t{public_key}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DS {
|
||||
zone: FQDN<'static>,
|
||||
_ttl: u32,
|
||||
key_tag: u16,
|
||||
algorithm: u8,
|
||||
digest_type: u8,
|
||||
digest: String,
|
||||
}
|
||||
|
||||
impl FromStr for DS {
|
||||
type Err = Error;
|
||||
|
||||
fn from_str(input: &str) -> Result<Self, Self::Err> {
|
||||
let mut columns = input.split_whitespace();
|
||||
|
||||
let [Some(zone), Some(ttl), Some(class), Some(record_type), Some(key_tag), Some(algorithm), Some(digest_type), Some(digest), None] =
|
||||
array::from_fn(|_| columns.next())
|
||||
else {
|
||||
return Err("expected 8 columns".into());
|
||||
};
|
||||
|
||||
let expected = "DS";
|
||||
if record_type != expected {
|
||||
return Err(
|
||||
format!("tried to parse `{record_type}` entry as a {expected} entry").into(),
|
||||
);
|
||||
}
|
||||
|
||||
if class != "IN" {
|
||||
return Err(format!("unknown class: {class}").into());
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
zone: zone.parse()?,
|
||||
_ttl: ttl.parse()?,
|
||||
key_tag: key_tag.parse()?,
|
||||
algorithm: algorithm.parse()?,
|
||||
digest_type: digest_type.parse()?,
|
||||
digest: digest.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// NOTE does NOT include the TTL field
|
||||
impl fmt::Display for DS {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let Self {
|
||||
zone,
|
||||
_ttl,
|
||||
key_tag,
|
||||
algorithm,
|
||||
digest_type,
|
||||
digest,
|
||||
} = self;
|
||||
|
||||
write!(
|
||||
f,
|
||||
"{zone}\tIN\tDS\t{key_tag}\t{algorithm}\t{digest_type}\t{digest}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NS<'a> {
|
||||
pub zone: FQDN<'a>,
|
||||
pub nameserver: FQDN<'a>,
|
||||
}
|
||||
|
||||
impl fmt::Display for NS<'_> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let Self {
|
||||
zone,
|
||||
nameserver: ns,
|
||||
} = self;
|
||||
|
||||
write!(f, "{zone}\tIN\tNS\t{ns}")
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SOA<'a> {
|
||||
pub zone: FQDN<'a>,
|
||||
pub nameserver: FQDN<'a>,
|
||||
pub admin: FQDN<'a>,
|
||||
pub settings: SoaSettings,
|
||||
}
|
||||
|
||||
impl fmt::Display for SOA<'_> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let Self {
|
||||
zone,
|
||||
nameserver: ns,
|
||||
admin,
|
||||
settings,
|
||||
} = self;
|
||||
|
||||
write!(f, "{zone}\tIN\tSOA\t{ns}\t{admin}\t{settings}")
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SoaSettings {
|
||||
pub serial: u32,
|
||||
pub refresh: u32,
|
||||
pub retry: u32,
|
||||
pub expire: u32,
|
||||
pub minimum: u32,
|
||||
}
|
||||
|
||||
impl Default for SoaSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
serial: 2024010101,
|
||||
refresh: 1800, // 30 minutes
|
||||
retry: 900, // 15 minutes
|
||||
expire: 604800, // 1 week
|
||||
minimum: 86400, // 1 day
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for SoaSettings {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let Self {
|
||||
serial,
|
||||
refresh,
|
||||
retry,
|
||||
expire,
|
||||
minimum,
|
||||
} = self;
|
||||
|
||||
write!(f, "( {serial} {refresh} {retry} {expire} {minimum} )")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::Result;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_to_string() -> Result<()> {
|
||||
let expected = "e.gtld-servers.net. IN A 192.12.94.30";
|
||||
let a = example_a()?;
|
||||
assert_eq!(expected, a.to_string());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ns_to_string() -> Result<()> {
|
||||
let expected = "com. IN NS e.gtld-servers.net.";
|
||||
let ns = example_ns()?;
|
||||
assert_eq!(expected, ns.to_string());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_to_string() -> Result<()> {
|
||||
let expected = ". 3600000 NS a.root-servers.net.
|
||||
a.root-servers.net. 3600000 A 198.41.0.4";
|
||||
let root = Root::new(FQDN("a.root-servers.net.")?, Ipv4Addr::new(198, 41, 0, 4));
|
||||
assert_eq!(expected, root.to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soa_to_string() -> Result<()> {
|
||||
let expected =
|
||||
". IN SOA a.root-servers.net. nstld.verisign-grs.com. ( 2024010101 1800 900 604800 86400 )";
|
||||
let soa = example_soa()?;
|
||||
assert_eq!(expected, soa.to_string());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zone_file_to_string() -> Result<()> {
|
||||
let expected = "$ORIGIN .
|
||||
$TTL 1800
|
||||
. IN SOA a.root-servers.net. nstld.verisign-grs.com. ( 2024010101 1800 900 604800 86400 )
|
||||
com. IN NS e.gtld-servers.net.
|
||||
e.gtld-servers.net. IN A 192.12.94.30
|
||||
";
|
||||
let mut zone = ZoneFile::new(FQDN::ROOT, example_soa()?);
|
||||
zone.entry(example_ns()?);
|
||||
zone.entry(example_a()?);
|
||||
|
||||
assert_eq!(expected, zone.to_string());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// not quite roundtrip because we drop the TTL field when doing `to_string`
|
||||
#[test]
|
||||
fn ds_roundtrip() -> Result<()> {
|
||||
let input =
|
||||
". 1800 IN DS 31153 7 2 7846338aaacde9cc9518f1f450082adc015a207c45a1e69d6e660e6836f4ef3b";
|
||||
let ds: DS = input.parse()?;
|
||||
let output = ds.to_string();
|
||||
|
||||
let expected =
|
||||
". IN DS 31153 7 2 7846338aaacde9cc9518f1f450082adc015a207c45a1e69d6e660e6836f4ef3b";
|
||||
assert_eq!(expected, output);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dnskey_roundtrip() -> Result<()> {
|
||||
let input = "example.com. IN DNSKEY 256 3 7 AwEAAdIpMlio4GJas7GbIZ9xRpzpB2pf4SxBJcsquN/0yNBPGNE2rzcFykqMAKmLwypk1/1q/EdHVa4tQ5RlK0w09CRhgSXfCaph+yLNJKpiPyuVcXKl2k0RnO4p835sgVEUIvx8qGTDo7c7DA9UBje+/3ViFKqVhOBaWyT6gHAmNVpb ;{id = 24975 (zsk), size = 1024b}";
|
||||
|
||||
let dnskey: DNSKEY = input.parse()?;
|
||||
|
||||
assert_eq!(256, dnskey.flags);
|
||||
assert_eq!(3, dnskey.protocol);
|
||||
assert_eq!(7, dnskey.algorithm);
|
||||
let expected = "AwEAAdIpMlio4GJas7GbIZ9xRpzpB2pf4SxBJcsquN/0yNBPGNE2rzcFykqMAKmLwypk1/1q/EdHVa4tQ5RlK0w09CRhgSXfCaph+yLNJKpiPyuVcXKl2k0RnO4p835sgVEUIvx8qGTDo7c7DA9UBje+/3ViFKqVhOBaWyT6gHAmNVpb";
|
||||
assert_eq!(expected, dnskey.public_key);
|
||||
assert_eq!(1024, dnskey.bits());
|
||||
assert_eq!(24975, dnskey.key_tag());
|
||||
|
||||
let output = dnskey.to_string();
|
||||
assert!(input.starts_with(&output));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn example_a() -> Result<A<'static>> {
|
||||
Ok(A {
|
||||
fqdn: FQDN("e.gtld-servers.net.")?,
|
||||
ipv4_addr: Ipv4Addr::new(192, 12, 94, 30),
|
||||
})
|
||||
}
|
||||
|
||||
fn example_ns() -> Result<NS<'static>> {
|
||||
Ok(NS {
|
||||
zone: FQDN::COM,
|
||||
nameserver: FQDN("e.gtld-servers.net.")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn example_soa() -> Result<SOA<'static>> {
|
||||
Ok(SOA {
|
||||
zone: FQDN::ROOT,
|
||||
nameserver: FQDN("a.root-servers.net.")?,
|
||||
admin: FQDN("nstld.verisign-grs.com.")?,
|
||||
settings: SoaSettings::default(),
|
||||
})
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user