dns: NSS module implementation
This commit is contained in:
parent
3beee1cf84
commit
03fbdb2d26
23 changed files with 4044 additions and 1 deletions
142
nss/tests/common/dbus.rs
Normal file
142
nss/tests/common/dbus.rs
Normal file
|
@ -0,0 +1,142 @@
|
|||
// SPDX-FileCopyrightText: Matteo Settenvini <matteo.settenvini@montecristosoftware.eu>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
include!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/src/policy_checker/dbus.rs"
|
||||
));
|
||||
|
||||
use {
|
||||
std::sync::atomic::{AtomicUsize, Ordering},
|
||||
tokio_util::sync::CancellationToken,
|
||||
zbus::dbus_interface,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct MalcontentDBusMock {
|
||||
responses: Vec<Restrictions>,
|
||||
invocations_left: AtomicUsize,
|
||||
}
|
||||
|
||||
#[dbus_interface(name = "com.endlessm.ParentalControls.Dns")]
|
||||
impl MalcontentDBusMock {
|
||||
fn get_dns(&mut self) -> Restrictions {
|
||||
let restrictions = self
|
||||
.responses
|
||||
.pop()
|
||||
.expect("MockError: DBus mock is saturated");
|
||||
self.invocations_left.fetch_sub(1, Ordering::SeqCst);
|
||||
restrictions
|
||||
}
|
||||
}
|
||||
|
||||
impl MalcontentDBusMock {
|
||||
pub fn new(mut responses: Vec<Restrictions>) -> Self {
|
||||
responses.reverse(); // We pop responses from the back, so...
|
||||
Self {
|
||||
invocations_left: AtomicUsize::new(responses.len()),
|
||||
responses,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MalcontentDBusMock {
|
||||
fn drop(&mut self) {
|
||||
let invocations_left = self.invocations_left.load(Ordering::Acquire);
|
||||
assert_eq!(
|
||||
invocations_left, 0,
|
||||
"MockError: During teardown, {} invocations are still left on the mock object",
|
||||
invocations_left
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DBusMockServer {
|
||||
handle: std::thread::JoinHandle<Result<()>>,
|
||||
cancellation: CancellationToken,
|
||||
}
|
||||
|
||||
impl DBusMockServer {
|
||||
pub fn new(responses: Vec<Restrictions>) -> Result<Self> {
|
||||
let token = CancellationToken::new();
|
||||
let cloned_token = token.clone();
|
||||
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
|
||||
std::env::set_var("TEST_DBUS_SOCKET", format!("{}", listener.local_addr()?));
|
||||
|
||||
let handle = std::thread::spawn(move || {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap()
|
||||
.block_on(Self::spawn_async(responses, listener, cloned_token))
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
handle: handle,
|
||||
cancellation: token,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn stop(self) -> Result<()> {
|
||||
self.cancellation.cancel();
|
||||
self.handle.join().unwrap()
|
||||
}
|
||||
|
||||
async fn spawn_async(
|
||||
responses: Vec<Restrictions>,
|
||||
listener: std::net::TcpListener,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> Result<()> {
|
||||
listener.set_nonblocking(true)?;
|
||||
let listener = tokio::net::TcpListener::from_std(listener)?;
|
||||
|
||||
let guid = zbus::Guid::generate();
|
||||
let mock = MalcontentDBusMock::new(responses);
|
||||
let (stream, _) = listener
|
||||
.accept()
|
||||
.await
|
||||
.expect("Server socket closed unexpectedly");
|
||||
|
||||
log::trace!("dbus mock server accepted client connection");
|
||||
let _connection = zbus::ConnectionBuilder::tcp_stream(stream)
|
||||
.server(&guid)
|
||||
.p2p()
|
||||
.auth_mechanisms(&[zbus::AuthMechanism::Anonymous])
|
||||
.name("com.endlessm.ParentalControls")
|
||||
.expect("Unable to serve given dbus name")
|
||||
.serve_at("/com/endlessm/ParentalControls/Dns", mock)
|
||||
.expect("Unable to server malcontent dbus mock object")
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
tokio::select! {
|
||||
_ = cancellation_token.cancelled() => {
|
||||
Ok(())
|
||||
}
|
||||
_ = std::future::pending::<()>() => { unreachable!() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DBusMockServerGuard {
|
||||
mock: Option<DBusMockServer>,
|
||||
}
|
||||
|
||||
impl DBusMockServerGuard {
|
||||
pub fn new(responses: Vec<Restrictions>) -> Result<Self> {
|
||||
Ok(Self {
|
||||
mock: Some(DBusMockServer::new(responses)?),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DBusMockServerGuard {
|
||||
fn drop(&mut self) {
|
||||
self.mock
|
||||
.take()
|
||||
.unwrap()
|
||||
.stop()
|
||||
.expect("cannot stop dbus server mock");
|
||||
}
|
||||
}
|
148
nss/tests/common/mod.rs
Normal file
148
nss/tests/common/mod.rs
Normal file
|
@ -0,0 +1,148 @@
|
|||
// SPDX-FileCopyrightText: Matteo Settenvini <matteo.settenvini@montecristosoftware.eu>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#![allow(non_upper_case_globals)]
|
||||
#![allow(non_camel_case_types)]
|
||||
#![allow(non_snake_case)]
|
||||
#![allow(dead_code)]
|
||||
|
||||
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
|
||||
|
||||
mod dbus;
|
||||
|
||||
use {
|
||||
anyhow::{anyhow, bail, ensure, Result},
|
||||
libc::{freeaddrinfo, gai_strerror, getaddrinfo},
|
||||
nix::sys::socket::{SockaddrLike as _, SockaddrStorage},
|
||||
std::env,
|
||||
std::ffi::{CStr, CString},
|
||||
std::net::{IpAddr, Ipv4Addr, Ipv6Addr},
|
||||
std::path::PathBuf,
|
||||
std::process::Command,
|
||||
std::str::FromStr,
|
||||
};
|
||||
|
||||
pub use self::dbus::{DBusMockServerGuard, Restriction, Restrictions};
|
||||
|
||||
pub type Eai = EaiRetcode;
|
||||
|
||||
#[static_init::dynamic]
|
||||
static GLOBAL_SETUP: () = {
|
||||
let cdylib_path = test_cdylib::build_current_project();
|
||||
let file_name = cdylib_path.file_name().unwrap();
|
||||
|
||||
let mut versioned_file_name = file_name.to_owned();
|
||||
versioned_file_name.push(".2"); // required for NSS 3 modules
|
||||
|
||||
let dest = PathBuf::from(std::env::current_exe().unwrap()).with_file_name(versioned_file_name);
|
||||
let _ = std::fs::remove_file(&dest);
|
||||
std::fs::hard_link(&cdylib_path, &dest).expect(&format!(
|
||||
"Cannot hard link {} to {}",
|
||||
cdylib_path.display(),
|
||||
dest.display()
|
||||
));
|
||||
};
|
||||
|
||||
/// Note that this should be called once per process; this implies that
|
||||
/// each test calling this function is in a separate process too.
|
||||
pub fn setup() -> Result<()> {
|
||||
let _ = env_logger::builder()
|
||||
.is_test(true)
|
||||
.filter_level(log::LevelFilter::max())
|
||||
.try_init();
|
||||
|
||||
std::env::set_var("NSS_MALCONTENT_LOG", "trace");
|
||||
|
||||
// setup NSS to load our cdylib, now versioned
|
||||
let db = CString::new("hosts").unwrap();
|
||||
let resolvers = CString::new("malcontent dns").unwrap();
|
||||
let nss_config_status = unsafe { __nss_configure_lookup(db.as_ptr(), resolvers.as_ptr()) };
|
||||
|
||||
if nss_config_status != 0 {
|
||||
panic!(
|
||||
"Unable to configure NSS to load module: __nss_configure_lookup() returned {}",
|
||||
nss_config_status
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn resolve_with_system(family: libc::c_int, host: &str) -> Result<IpAddr> {
|
||||
let process = Command::new("getent")
|
||||
.arg(match family {
|
||||
libc::AF_INET => "ahostsv4",
|
||||
libc::AF_INET6 => "ahostsv6",
|
||||
_ => panic!("INET family must be either IPv4 or IPv6"),
|
||||
})
|
||||
.arg(host)
|
||||
.output()?;
|
||||
ensure!(
|
||||
process.status.success(),
|
||||
"Failed to run getent to check host IP"
|
||||
);
|
||||
let output = String::from_utf8(process.stdout)?;
|
||||
log::trace!("system getent reports: {}", &output);
|
||||
let addr_string = output
|
||||
.as_str()
|
||||
.split(' ')
|
||||
.next()
|
||||
.ok_or(anyhow!("Unparseable output from getent"))?;
|
||||
Ok(IpAddr::from_str(&addr_string)?)
|
||||
}
|
||||
|
||||
pub fn resolve_with_module(family: libc::c_int, hostname: &str) -> Result<Vec<IpAddr>> {
|
||||
let c_hostname = CString::new(hostname).unwrap();
|
||||
unsafe {
|
||||
let mut addr = std::ptr::null_mut();
|
||||
let hints = libc::addrinfo {
|
||||
ai_family: family,
|
||||
ai_flags: 0,
|
||||
ai_socktype: 0,
|
||||
ai_protocol: 0,
|
||||
ai_addrlen: 0,
|
||||
ai_addr: std::ptr::null_mut(),
|
||||
ai_canonname: std::ptr::null_mut(),
|
||||
ai_next: std::ptr::null_mut(),
|
||||
};
|
||||
let getaddrinfo_status =
|
||||
getaddrinfo(c_hostname.as_ptr(), std::ptr::null(), &hints, &mut addr);
|
||||
|
||||
let error = CStr::from_ptr(gai_strerror(getaddrinfo_status));
|
||||
assert_eq!(
|
||||
getaddrinfo_status,
|
||||
Eai::Success.0,
|
||||
"Should have gotten hostname for {}, instead got {}",
|
||||
hostname,
|
||||
error.to_str().unwrap()
|
||||
);
|
||||
|
||||
let ips = convert_addrinfo(addr)?;
|
||||
freeaddrinfo(addr);
|
||||
Ok(ips)
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn convert_addrinfo(addrs: *const libc::addrinfo) -> Result<Vec<IpAddr>> {
|
||||
let mut ips = vec![];
|
||||
|
||||
let mut addr = addrs;
|
||||
while addr != std::ptr::null() {
|
||||
let addr_storage = SockaddrStorage::from_raw((*addr).ai_addr, Some((*addr).ai_addrlen))
|
||||
.ok_or(anyhow!("Garbled addrinfo from getaddrinfo()"))?;
|
||||
|
||||
if let Some(addr) = addr_storage.as_sockaddr_in() {
|
||||
ips.push(IpAddr::V4(Ipv4Addr::from(addr.ip())));
|
||||
} else if let Some(addr) = addr_storage.as_sockaddr_in6() {
|
||||
ips.push(IpAddr::V6(Ipv6Addr::from(addr.ip())));
|
||||
} else {
|
||||
bail!("addrinfo is not either an IPv4 or IPv6 address")
|
||||
}
|
||||
|
||||
addr = (*addr).ai_next;
|
||||
}
|
||||
|
||||
ips.sort();
|
||||
ips.dedup();
|
||||
Ok(ips)
|
||||
}
|
243
nss/tests/integration_test.rs
Normal file
243
nss/tests/integration_test.rs
Normal file
|
@ -0,0 +1,243 @@
|
|||
// SPDX-FileCopyrightText: Matteo Settenvini <matteo.settenvini@montecristosoftware.eu>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
mod common;
|
||||
|
||||
use {
|
||||
crate::common::{Eai, Restriction, Restrictions},
|
||||
anyhow::Result,
|
||||
elf::ElfStream,
|
||||
libc::{freeaddrinfo, gai_strerror, getaddrinfo},
|
||||
once_cell::sync::Lazy,
|
||||
rusty_fork::rusty_fork_test,
|
||||
std::net::{IpAddr, Ipv4Addr, Ipv6Addr},
|
||||
std::os::unix::fs::FileExt,
|
||||
std::path::PathBuf,
|
||||
};
|
||||
|
||||
static DNS0_PARENTALCONTROL_ADDRS: Lazy<Restrictions> = Lazy::new(|| {
|
||||
vec![
|
||||
Restriction {
|
||||
ip: IpAddr::V4(Ipv4Addr::new(193, 110, 81, 1)),
|
||||
hostname: "kids.dns0.eu".into(),
|
||||
},
|
||||
Restriction {
|
||||
ip: IpAddr::V4(Ipv4Addr::new(185, 253, 5, 1)),
|
||||
hostname: "kids.dns0.eu".into(),
|
||||
},
|
||||
Restriction {
|
||||
ip: IpAddr::V6(Ipv6Addr::new(0x2a0f, 0xfc80, 0, 0, 0, 0, 0, 0x1)),
|
||||
hostname: "kids.dns0.eu".into(),
|
||||
},
|
||||
Restriction {
|
||||
ip: IpAddr::V6(Ipv6Addr::new(0x2a0f, 0xfc81, 0, 0, 0, 0, 0, 0x1)),
|
||||
hostname: "kids.dns0.eu".into(),
|
||||
},
|
||||
]
|
||||
});
|
||||
|
||||
static CLOUDFLARE_PARENTALCONTROL_ADDRS: Lazy<Restrictions> = Lazy::new(|| {
|
||||
vec![
|
||||
Restriction {
|
||||
ip: IpAddr::V4(Ipv4Addr::new(1, 1, 1, 3)),
|
||||
hostname: "family.cloudflare-dns.com".into(),
|
||||
},
|
||||
Restriction {
|
||||
ip: IpAddr::V4(Ipv4Addr::new(1, 0, 0, 3)),
|
||||
hostname: "family.cloudflare-dns.com".into(),
|
||||
},
|
||||
Restriction {
|
||||
ip: IpAddr::V6(Ipv6Addr::new(0x2606, 0x4700, 0x4700, 0, 0, 0, 0, 0x1113)),
|
||||
hostname: "family.cloudflare-dns.com".into(),
|
||||
},
|
||||
Restriction {
|
||||
ip: IpAddr::V6(Ipv6Addr::new(0x2606, 0x4700, 0x4700, 0, 0, 0, 0, 0x1003)),
|
||||
hostname: "family.cloudflare-dns.com".into(),
|
||||
},
|
||||
]
|
||||
});
|
||||
|
||||
static NO_RESTRICTIONS: Lazy<Restrictions> = Lazy::new(|| vec![]);
|
||||
|
||||
#[test]
|
||||
fn sanity_check() -> Result<()> {
|
||||
let current_exe = PathBuf::from(std::env::current_exe().unwrap());
|
||||
let myself = std::fs::File::open(¤t_exe)?;
|
||||
let mut elf_stream: ElfStream<elf::endian::AnyEndian, _> =
|
||||
elf::ElfStream::open_stream(&myself)?;
|
||||
let dynamic_section = elf_stream.dynamic()?.expect("No dynamic table");
|
||||
let rpath_off = dynamic_section
|
||||
.iter()
|
||||
.find(|x| x.d_tag == elf::abi::DT_RPATH)
|
||||
.map(|x| x.d_val())
|
||||
.expect("No DT_RPATH found in test executable ELF dyn section");
|
||||
let strtable = dynamic_section
|
||||
.into_iter()
|
||||
.find(|x| x.d_tag == elf::abi::DT_STRTAB)
|
||||
.map(|x| x.d_ptr())
|
||||
.expect("No DT_STRTAB found in test executable ELF dyn section");
|
||||
|
||||
let mut buf = [0u8; 1024];
|
||||
let _ = myself.read_at(buf.as_mut_slice(), strtable + rpath_off)?;
|
||||
let end = buf
|
||||
.iter()
|
||||
.position(|c| *c == 0u8)
|
||||
.expect(&format!("RPATH is bigger than {} bytes", buf.len()));
|
||||
let rpath = std::str::from_utf8(&buf[..end])?;
|
||||
assert_eq!(rpath, "$ORIGIN");
|
||||
|
||||
common::setup()?;
|
||||
let lib = current_exe.parent().unwrap().join("libnss_malcontent.so.2");
|
||||
assert!(lib.exists());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
rusty_fork_test! {
|
||||
#[test]
|
||||
fn application_dns_is_nodata() {
|
||||
common::setup().unwrap();
|
||||
|
||||
let _dbus = common::DBusMockServerGuard::new(vec![CLOUDFLARE_PARENTALCONTROL_ADDRS.clone()]).unwrap();
|
||||
|
||||
let hostname = std::ffi::CString::new("use-application-dns.net").unwrap();
|
||||
unsafe {
|
||||
let mut addr = std::ptr::null_mut();
|
||||
let getaddrinfo_status = getaddrinfo(
|
||||
hostname.as_ptr(),
|
||||
std::ptr::null(),
|
||||
std::ptr::null(),
|
||||
&mut addr,
|
||||
);
|
||||
|
||||
let error = std::ffi::CStr::from_ptr(gai_strerror(getaddrinfo_status));
|
||||
assert_eq!(
|
||||
getaddrinfo_status,
|
||||
Eai::NoData.0,
|
||||
"Should have gotten no data (NODATA), instead got {}",
|
||||
error.to_str().unwrap()
|
||||
);
|
||||
freeaddrinfo(addr);
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn getaddrinfo_resolution() {
|
||||
common::setup().unwrap();
|
||||
|
||||
const HOSTNAME: &str = "gnome.org";
|
||||
|
||||
let _dbus = common::DBusMockServerGuard::new(vec![CLOUDFLARE_PARENTALCONTROL_ADDRS.clone()]).unwrap();
|
||||
|
||||
unsafe {
|
||||
let mut addr = std::ptr::null_mut();
|
||||
let hostname = std::ffi::CString::new(HOSTNAME).unwrap();
|
||||
let getaddrinfo_status = getaddrinfo(
|
||||
hostname.as_ptr(),
|
||||
std::ptr::null(),
|
||||
std::ptr::null(),
|
||||
&mut addr,
|
||||
);
|
||||
|
||||
let error = std::ffi::CStr::from_ptr(gai_strerror(getaddrinfo_status));
|
||||
assert_eq!(
|
||||
getaddrinfo_status,
|
||||
Eai::Success.0,
|
||||
"Should have gotten an hostname, instead got {}",
|
||||
error.to_str().unwrap()
|
||||
);
|
||||
freeaddrinfo(addr);
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wikipedia_is_unrestricted() {
|
||||
common::setup().unwrap();
|
||||
|
||||
const HOSTNAME: &str = "wikipedia.org";
|
||||
|
||||
let _dbus = common::DBusMockServerGuard::new(vec![CLOUDFLARE_PARENTALCONTROL_ADDRS.clone()]).unwrap();
|
||||
|
||||
for family in [libc::AF_INET, libc::AF_INET6] {
|
||||
let system_addr = common::resolve_with_system(family, HOSTNAME).unwrap();
|
||||
let our_addrs = common::resolve_with_module(family, HOSTNAME).unwrap();
|
||||
assert!(our_addrs.contains(&system_addr));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dns0_adultsites_are_restricted() {
|
||||
common::setup().unwrap();
|
||||
|
||||
const HOSTNAME: &str = "pornhub.com";
|
||||
|
||||
let _dbus = common::DBusMockServerGuard::new(vec![DNS0_PARENTALCONTROL_ADDRS.clone()]).unwrap();
|
||||
|
||||
let our_addrs_ipv4 = common::resolve_with_module(libc::AF_INET, HOSTNAME).unwrap();
|
||||
let our_addrs_ipv6 = common::resolve_with_module(libc::AF_INET, HOSTNAME).unwrap();
|
||||
assert!(
|
||||
our_addrs_ipv4.is_empty(),
|
||||
"Resolver answered with {:?}, should be empty",
|
||||
our_addrs_ipv4
|
||||
);
|
||||
assert!(
|
||||
our_addrs_ipv6.is_empty(),
|
||||
"Resolver answered with {:?}, should be empty",
|
||||
our_addrs_ipv6
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cloudflare_adultsite_is_restricted_ipv4() {
|
||||
common::setup().unwrap();
|
||||
|
||||
const HOSTNAME: &str = "nudity.testcategory.com";
|
||||
|
||||
let _dbus = common::DBusMockServerGuard::new(vec![CLOUDFLARE_PARENTALCONTROL_ADDRS.clone()]).unwrap();
|
||||
|
||||
let system_addr = common::resolve_with_system(libc::AF_INET, HOSTNAME).unwrap();
|
||||
let our_addrs = common::resolve_with_module(libc::AF_INET, HOSTNAME).unwrap();
|
||||
assert!(
|
||||
!our_addrs.contains(&system_addr),
|
||||
"Resolver answered with {:?}, should not contain {}",
|
||||
our_addrs,
|
||||
system_addr
|
||||
);
|
||||
assert_eq!(our_addrs, [IpAddr::V4(Ipv4Addr::UNSPECIFIED)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cloudflare_adultsite_is_restricted_ipv6() {
|
||||
common::setup().unwrap();
|
||||
|
||||
const HOSTNAME: &str = "nudity.testcategory.com";
|
||||
|
||||
let _dbus = common::DBusMockServerGuard::new(vec![CLOUDFLARE_PARENTALCONTROL_ADDRS.clone()]).unwrap();
|
||||
|
||||
let system_addr = common::resolve_with_system(libc::AF_INET6, HOSTNAME).unwrap();
|
||||
let our_addrs = common::resolve_with_module(libc::AF_INET6, HOSTNAME).unwrap();
|
||||
assert!(
|
||||
!our_addrs.contains(&system_addr),
|
||||
"Resolver answered with {:?}, should not contain {}",
|
||||
our_addrs,
|
||||
system_addr
|
||||
);
|
||||
assert_eq!(our_addrs, [IpAddr::V6(Ipv6Addr::UNSPECIFIED)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn privileged_user_bypasses_restrictions() {
|
||||
common::setup().unwrap();
|
||||
|
||||
const HOSTNAME: &str = "malware.testcategory.com";
|
||||
|
||||
let _dbus = common::DBusMockServerGuard::new(vec![NO_RESTRICTIONS.clone()]).unwrap();
|
||||
|
||||
for family in [libc::AF_INET, libc::AF_INET6] {
|
||||
let system_addr = common::resolve_with_system(family, HOSTNAME).unwrap();
|
||||
let our_addrs = common::resolve_with_module(family, HOSTNAME).unwrap();
|
||||
assert!(our_addrs.contains(&system_addr));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue