Add TLS support to resolver, implement DBus ifaces

This commit is contained in:
Matteo Settenvini 2022-08-24 14:46:14 +02:00
parent b5797b12f1
commit c52195dd8b
Signed by: matteo
GPG Key ID: 8576CC1AD97D42DF
12 changed files with 308 additions and 221 deletions

5
.gitignore vendored
View File

@ -1,7 +1,10 @@
# SPDX-FileCopyrightText: 2022 Matteo Settenvini <matteo.settenvini@montecristosoftware.eu>
# SPDX-License-Identifier: CC0-1.0
*.orig
*~
/build
/target
/Cargo.lock
/.gdb_history
/.gdb_history

View File

@ -22,6 +22,7 @@ name = "nss_malcontent"
bindgen = "0.60"
[dev-dependencies]
event-listener = "2.5"
futures-util = "0.3"
rusty-hook = "0.11"
rusty-forkfork = "0.4"
@ -30,12 +31,14 @@ tokio = { version = "1", features = ["rt", "sync", "macros", "time"] }
[dependencies]
anyhow = "1.0"
const_format = "0.2"
gethostname = "0.2"
libc = "0.2"
once_cell = "1.13"
log = "0.4"
nix = { version = "0.24", features = ["socket", "user", "sched"] }
serde = "1.0"
tokio = { version = "1", features = ["rt"] }
trust-dns-resolver = "0.21"
trust-dns-resolver = { version = "0.21", features = ["dns-over-rustls"] }
trust-dns-proto = "0.21"
zbus = { version = "3.0", default-features = false, features = ["tokio"] }
zvariant = "3.6"

View File

@ -113,6 +113,7 @@ exceptions = [
# list
#{ allow = ["Zlib"], name = "adler32", version = "*" },
{ allow = ["Unicode-DFS-2016"], name = "unicode-ident", version = "*" },
{ allow = ["OpenSSL"], name = "ring", version = "*" },
]
# Some crates don't have (easily) machine readable licensing information,
@ -135,6 +136,13 @@ exceptions = [
#{ path = "LICENSE", hash = 0xbd0eed23 }
#]
[[licenses.clarify]]
name = "ring"
expression = "MIT AND ISC AND OpenSSL"
license-files = [
{ path = "LICENSE", hash = 0xbd0eed23 },
]
[licenses.private]
# If true, ignores workspace crates that aren't published, or are only
# published to private registries.

View File

@ -1,13 +0,0 @@
// SPDX-FileCopyrightText: 2022 Matteo Settenvini <matteo.settenvini@montecristosoftware.eu>
// SPDX-License-Identifier: GPL-3.0-or-later
use const_format::concatcp;
#[allow(dead_code)]
const DBUS_INTERFACE: &str = "com.endlessm.ParentalControls.Dns";
#[allow(dead_code)]
const DBUS_OBJECT_PATH: &str = "com/endlessm/ParentalControls/Dns";
#[allow(dead_code)]
const DBUS_GET_RESTRICTIONS_METHOD: &str = concatcp!(DBUS_INTERFACE, ".", "GetRestrictions");

View File

@ -4,7 +4,7 @@
use {
crate::helpers::{set_if_valid, write_record_name_to_buf, write_vector_to_buf},
crate::nss_bindings::{gaih_addrtuple, nss_status, HErrno},
crate::policy_checker::{PolicyChecker as _, POLICY_CHECKER},
crate::policy_checker::POLICY_CHECKER,
libc::{c_char, c_int, hostent, size_t, AF_INET, AF_INET6},
nix::errno,
nix::errno::Errno,
@ -46,7 +46,7 @@ pub async unsafe fn with(args: &mut Args) -> nss_status {
set_if_valid(args.errnop, errno::from_i32(0));
set_if_valid(args.h_errnop, HErrno::Success);
match POLICY_CHECKER.resolver(None) {
match POLICY_CHECKER.resolver(None).await {
Ok(None) => {
// no restrictions for user, the next NSS module will decide
nss_status::NSS_STATUS_NOTFOUND

View File

@ -5,7 +5,6 @@
#![allow(non_camel_case_types)]
#![allow(non_snake_case)]
mod constants;
mod gethostbyaddr;
mod gethostbyname;
mod helpers;

View File

@ -1,102 +0,0 @@
// SPDX-FileCopyrightText: 2022 Matteo Settenvini <matteo.settenvini@montecristosoftware.eu>
// SPDX-License-Identifier: GPL-3.0-or-later
use {
anyhow::Result,
nix::unistd::{getuid, Uid},
once_cell::sync::Lazy,
std::collections::HashMap,
std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
std::sync::{Arc, RwLock},
trust_dns_resolver::config as dns_config,
trust_dns_resolver::TokioAsyncResolver,
};
pub type Restrictions<'a> = &'a [IpAddr];
pub trait PolicyChecker {
fn resolver(&self, user: Option<Uid>) -> Result<Option<Arc<TokioAsyncResolver>>>;
}
pub static POLICY_CHECKER: Lazy<MalcontentPolicyChecker> =
Lazy::new(|| MalcontentPolicyChecker::new());
static CLOUDFLARE_PARENTALCONTROL_ADDRS: Lazy<Vec<IpAddr>> = Lazy::new(|| {
vec![
IpAddr::V4(Ipv4Addr::new(1, 1, 1, 3)),
IpAddr::V4(Ipv4Addr::new(1, 0, 0, 3)),
IpAddr::V6(Ipv6Addr::new(2606, 4700, 4700, 0, 0, 0, 0, 1113)),
IpAddr::V6(Ipv6Addr::new(2606, 4700, 4700, 0, 0, 0, 0, 1003)),
]
});
// TODO: accept notifications about config changes
pub struct MalcontentPolicyChecker {
resolvers: RwLock<HashMap<Uid, Option<Arc<TokioAsyncResolver>>>>,
}
impl MalcontentPolicyChecker {
pub fn new() -> Self {
Self {
resolvers: RwLock::new(HashMap::new()),
}
}
fn restrictions<'a>(&'a self, user: Uid) -> Result<Option<Restrictions<'a>>> {
if user.is_root() {
return Ok(None);
};
// TODO: for now, hardcoded DNS records, later go
// through D-Bus to malcontent and ask about user.
Ok(Some(CLOUDFLARE_PARENTALCONTROL_ADDRS.as_slice()))
}
}
impl PolicyChecker for MalcontentPolicyChecker {
fn resolver(&self, user: Option<Uid>) -> Result<Option<Arc<TokioAsyncResolver>>> {
let user = user.unwrap_or_else(|| getuid());
{
let ro_resolvers = self.resolvers.read().unwrap();
if let Some(resolver) = ro_resolvers.get(&user) {
return Ok(resolver.clone());
}
}
{
let mut rw_resolvers = self.resolvers.write().unwrap();
if rw_resolvers.contains_key(&user) {
return self.resolver(Some(user));
}
let resolver = match self.restrictions(user)? {
Some(addrs) => {
let resolver_config =
addrs
.iter()
.fold(dns_config::ResolverConfig::new(), |mut config, addr| {
config.add_name_server(dns_config::NameServerConfig {
socket_addr: SocketAddr::new(*addr, 53),
protocol: dns_config::Protocol::Udp,
tls_dns_name: None,
trust_nx_responses: true,
bind_addr: None,
});
config
});
let resolver = TokioAsyncResolver::tokio(
resolver_config,
dns_config::ResolverOpts::default(),
)?;
Some(Arc::new(resolver))
}
None => None,
};
rw_resolvers.insert(user, resolver);
}
self.resolver(Some(user))
}
}

View File

@ -0,0 +1,26 @@
// SPDX-FileCopyrightText: 2022 Matteo Settenvini <matteo.settenvini@montecristosoftware.eu>
// SPDX-License-Identifier: GPL-3.0-or-later
use {
serde::{Deserialize, Serialize},
std::net::IpAddr,
zbus::{dbus_proxy, Result},
};
#[dbus_proxy(
default_service = "com.endlessm.ParentalControls",
interface = "com.endlessm.ParentalControls.Dns",
default_path = "/com/endlessm/ParentalControls/Dns",
gen_blocking = false
)]
trait MalcontentDns {
fn get_restrictions(&self, uid: u32) -> Result<Restrictions>;
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, zvariant::Type)]
pub struct Restriction {
pub ip: IpAddr,
pub hostname: String,
}
pub type Restrictions = Vec<Restriction>;

111
src/policy_checker/mod.rs Normal file
View File

@ -0,0 +1,111 @@
// SPDX-FileCopyrightText: 2022 Matteo Settenvini <matteo.settenvini@montecristosoftware.eu>
// SPDX-License-Identifier: GPL-3.0-or-later
mod dbus;
use {
self::dbus::*,
anyhow::Result,
nix::unistd::{getuid, Uid},
once_cell::sync::Lazy,
std::collections::HashMap,
std::net::{SocketAddr, TcpStream},
std::sync::{Arc, RwLock},
trust_dns_proto::rr::domain::Name as DomainName,
trust_dns_resolver::config as dns_config,
trust_dns_resolver::TokioAsyncResolver,
};
pub use self::dbus::{Restriction, Restrictions};
const DNS_UDP_PORT: u16 = 53;
const DNS_TLS_PORT: u16 = 853;
pub static POLICY_CHECKER: Lazy<PolicyChecker> = Lazy::new(|| PolicyChecker::new());
// TODO: accept notifications about config changes
pub struct PolicyChecker {
resolvers: RwLock<HashMap<Uid, Option<Arc<TokioAsyncResolver>>>>,
}
impl PolicyChecker {
pub fn new() -> Self {
Self {
resolvers: RwLock::new(HashMap::new()),
}
}
async fn restrictions<'a>(&'a self, user: Uid) -> Result<Restrictions> {
if user.is_root() {
return Ok(vec![]);
};
let connection = zbus::Connection::session().await?;
let proxy = MalcontentDnsProxy::new(&connection).await?;
Ok(proxy.get_restrictions(user.as_raw()).await?)
}
pub async fn resolver(&self, user: Option<Uid>) -> Result<Option<Arc<TokioAsyncResolver>>> {
let user = user.unwrap_or_else(|| getuid());
// Check if already cached
{
let ro_resolvers = self.resolvers.read().unwrap();
if let Some(resolver) = ro_resolvers.get(&user) {
return Ok(resolver.clone());
}
}
// Else, initialize and cache it
{
let mut rw_resolvers = self.resolvers.write().unwrap();
if let Some(resolver) = rw_resolvers.get(&user) {
return Ok(resolver.clone());
}
// try first to prime resolver with DoT implementation,
// fallback to unencrypted only if not available.
let restrictions = self.restrictions(user).await?;
let resolver = if !restrictions.is_empty() {
let resolver = TokioAsyncResolver::tokio(
resolver_config_for(restrictions),
dns_config::ResolverOpts::default(),
)?;
Some(Arc::new(resolver))
} else {
None
};
rw_resolvers.insert(user, resolver.clone());
Ok(resolver)
}
}
}
fn resolver_config_for(restrictions: Vec<Restriction>) -> dns_config::ResolverConfig {
use dns_config::NameServerConfigGroup as NsConfig;
let resolver_config_group =
restrictions
.into_iter()
.fold(NsConfig::new(), |mut config, restr| {
let new_config =
if TcpStream::connect(SocketAddr::new(restr.ip, DNS_TLS_PORT)).is_ok() {
NsConfig::from_ips_tls(&[restr.ip], DNS_TLS_PORT, restr.hostname, true)
} else {
NsConfig::from_ips_clear(&[restr.ip], DNS_UDP_PORT, true)
};
config.merge(new_config);
config
});
let basename = gethostname::gethostname()
.as_os_str()
.to_str()
.map(|hn| DomainName::from_labels(hn.split('.')).ok())
.flatten()
.map(|hn| hn.base_name());
dns_config::ResolverConfig::from_parts(basename, vec![], resolver_config_group)
}

77
tests/common/dbus.rs Normal file
View File

@ -0,0 +1,77 @@
// SPDX-FileCopyrightText: 2022 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 {
event_listener::{Event, EventListener},
nix::unistd::Uid,
std::collections::HashMap,
zbus::dbus_interface,
};
pub struct MalcontentDBusMock {
responses: HashMap<Uid, Vec<Restrictions>>,
invocations_left: usize,
finished: Event,
}
#[dbus_interface(name = "com.endlessm.ParentalControls.Dns")]
impl MalcontentDBusMock {
fn get_restrictions(&mut self, user_id: u32) -> Restrictions {
let answers = self
.responses
.get_mut(&Uid::from_raw(user_id))
.expect(&format!(
"MockError: No mocked invocations available for user with id {}",
user_id
));
let restrictions = answers.pop().expect(&format!(
"MockError: DBus mock is saturated for user with id {}",
user_id
));
self.invocations_left -= 1;
if self.invocations_left == 0 {
self.finished.notify(1);
}
restrictions
}
}
impl MalcontentDBusMock {
pub fn new(mut responses: HashMap<Uid, Vec<Restrictions>>) -> Self {
let responses_size: usize = responses.values().map(|v| v.len()).sum();
for r in responses.values_mut() {
r.reverse(); // we pop responses from the back, so...
}
let ret = Self {
responses,
invocations_left: responses_size,
finished: Event::new(),
};
if ret.invocations_left == 0 {
ret.finished.notify(1);
}
ret
}
pub fn waiter(&self) -> EventListener {
self.finished.listen()
}
}
impl Drop for MalcontentDBusMock {
fn drop(&mut self) {
assert_eq!(
self.invocations_left, 0,
"MockError: During teardown, {} invocations are still left on the mock object",
self.invocations_left
);
}
}

View File

@ -7,11 +7,12 @@
#![allow(dead_code)]
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
include!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/constants.rs"));
mod dbus;
use {
self::dbus::MalcontentDBusMock,
anyhow::{anyhow, bail, ensure, Result},
futures_util::TryStreamExt,
libc::{freeaddrinfo, gai_strerror, getaddrinfo},
nix::sys::socket::{SockaddrLike as _, SockaddrStorage},
nix::unistd::Uid,
@ -27,6 +28,8 @@ use {
tokio::task::JoinHandle,
};
pub use self::dbus::{Restriction, Restrictions};
// Adapted from rusty_forkfork (which inherits it from rusty_fork)
// to allow a custom pre-fork function
#[macro_export]
@ -179,69 +182,16 @@ fn convert_addrinfo(sa: &SockaddrStorage) -> Result<IpAddr> {
}
}
pub type Restrictions = Vec<IpAddr>;
pub fn mock_dbus(responses: HashMap<Uid, Vec<Restrictions>>) -> JoinHandle<Result<()>> {
async fn dbus_loop(mut responses: HashMap<Uid, Vec<Restrictions>>) -> Result<()> {
use zbus::MessageType::*;
let mut responses_size: usize = responses.values().map(|v| v.len()).sum();
for r in responses.values_mut() {
r.reverse(); // we pop responses from the back, so...
}
let connection = zbus::ConnectionBuilder::session()?.build().await?;
let mut stream = zbus::MessageStream::from(&connection);
while responses_size > 0 {
let msg = stream
.try_next()
.await?
.ok_or(anyhow!("Unparseable DBus message"))?;
if msg.header()?.message_type()? == MethodCall
&& msg
.interface()
.ok_or(anyhow!("Invoked method has no interface"))?
== DBUS_INTERFACE
&& msg
.member()
.ok_or(anyhow!("Invoked method has no member"))?
== DBUS_GET_RESTRICTIONS_METHOD
{
let user_id: u32 = msg.body()?;
match responses.get_mut(&Uid::from_raw(user_id)) {
Some(answers) => match answers.pop() {
Some(a) => {
responses_size = responses_size - 1;
let ips: Vec<String> = a.into_iter().map(|ip| ip.to_string()).collect();
connection.reply(&msg, &ips).await
}
None => {
connection
.reply_error(
&msg,
"MockExhausted",
&format!("DBus mock is saturated for user with id {}", user_id),
)
.await
}
},
None => {
connection
.reply_error(
&msg,
"MockExhausted",
&format!(
"No mocked invocations available for user with id {}",
user_id
),
)
.await
}
}?;
}
}
async fn dbus_loop(responses: HashMap<Uid, Vec<Restrictions>>) -> Result<()> {
let mock = MalcontentDBusMock::new(responses);
let waiter = mock.waiter();
let _connection = zbus::ConnectionBuilder::session()?
.serve_at("/com/endlessm/ParentalControls/Dns", mock)?
.build()
.await?;
waiter.wait();
Ok(())
}

View File

@ -4,23 +4,35 @@
mod common;
use {
crate::common::Eai,
crate::common::{Eai, Restriction, Restrictions},
anyhow::Result,
libc::{freeaddrinfo, gai_strerror, getaddrinfo},
nix::unistd::getuid,
once_cell::sync::Lazy,
std::collections::HashMap,
std::net::{IpAddr, Ipv4Addr, Ipv6Addr},
//std::time::Duration,
//tokio::time::timeout,
std::time::Duration,
tokio::time::timeout,
};
static CLOUDFLARE_PARENTALCONTROL_ADDRS: Lazy<Vec<IpAddr>> = Lazy::new(|| {
static CLOUDFLARE_PARENTALCONTROL_ADDRS: Lazy<Restrictions> = Lazy::new(|| {
vec![
IpAddr::V4(Ipv4Addr::new(1, 1, 1, 3)),
IpAddr::V4(Ipv4Addr::new(1, 0, 0, 3)),
IpAddr::V6(Ipv6Addr::new(2606, 4700, 4700, 0, 0, 0, 0, 1113)),
IpAddr::V6(Ipv6Addr::new(2606, 4700, 4700, 0, 0, 0, 0, 1003)),
Restriction {
ip: IpAddr::V4(Ipv4Addr::new(1, 1, 1, 3)),
hostname: "cloudflare-dns.com".into(),
},
Restriction {
ip: IpAddr::V4(Ipv4Addr::new(1, 0, 0, 3)),
hostname: "cloudflare-dns.com".into(),
},
Restriction {
ip: IpAddr::V6(Ipv6Addr::new(2606, 4700, 4700, 0, 0, 0, 0, 1113)),
hostname: "cloudflare-dns.com".into(),
},
Restriction {
ip: IpAddr::V6(Ipv6Addr::new(2606, 4700, 4700, 0, 0, 0, 0, 1003)),
hostname: "cloudflare-dns.com".into(),
},
]
});
@ -36,7 +48,7 @@ fork_test! {
fn application_dns_is_nxdomain() -> Result<()> {
common::setup()?;
tokio::runtime::Runtime::new().unwrap().block_on(async {
let _dbus = common::mock_dbus(HashMap::from([(
let dbus = common::mock_dbus(HashMap::from([(
getuid(),
vec![CLOUDFLARE_PARENTALCONTROL_ADDRS.clone()],
)]));
@ -61,25 +73,26 @@ fork_test! {
freeaddrinfo(addr);
};
//timeout(Duration::from_secs(1), dbus).await??
Ok(())
timeout(Duration::from_secs(1), dbus).await??
})
}
#[test]
fn getaddrinfo_resolution() -> Result<()> {
common::setup()?;
const HOSTNAME: &str = "gnome.org";
tokio::runtime::Runtime::new().unwrap().block_on(async {
let _dbus = common::mock_dbus(HashMap::from([(
let dbus = common::mock_dbus(HashMap::from([(
getuid(),
vec![CLOUDFLARE_PARENTALCONTROL_ADDRS.clone()],
)]));
let hostname = std::ffi::CString::new("gnome.org").unwrap();
unsafe {
let mut addr = std::ptr::null_mut();
let getaddrinfo_status = getaddrinfo(
hostname.as_ptr(),
std::ffi::CString::new(HOSTNAME)?.as_ptr(),
std::ptr::null(),
std::ptr::null(),
&mut addr,
@ -95,8 +108,7 @@ fork_test! {
freeaddrinfo(addr);
};
//timeout(Duration::from_secs(1), dbus).await??
Ok(())
timeout(Duration::from_secs(1), dbus).await??
})
}
@ -105,48 +117,64 @@ fork_test! {
fn wikipedia_is_unrestricted() -> Result<()> {
common::setup()?;
const HOSTNAME: &str = "wikipedia.org";
tokio::runtime::Runtime::new().unwrap().block_on(async {
let _dbus = common::mock_dbus(HashMap::from([(
getuid(),
vec![CLOUDFLARE_PARENTALCONTROL_ADDRS.clone()],
)]));
const HOSTNAME: &str = "wikipedia.org";
for family in [libc::AF_INET, libc::AF_INET6] {
let dbus = common::mock_dbus(HashMap::from([(
getuid(),
vec![CLOUDFLARE_PARENTALCONTROL_ADDRS.clone()],
)]));
let system_addr = common::resolve_with_system(family, HOSTNAME)?;
let our_addr = common::resolve_with_module(family, HOSTNAME)?;
assert_eq!(system_addr, our_addr);
}
//timeout(Duration::from_secs(1), dbus).await??
timeout(Duration::from_secs(1), dbus).await???;
}
Ok(())
})
}
#[test]
fn adultsite_is_restricted() -> Result<()> {
fn adultsite_is_restricted_ipv4() -> Result<()> {
common::setup()?;
const HOSTNAME: &str = "nudity.testcategory.com";
tokio::runtime::Runtime::new().unwrap().block_on(async {
let _dbus = common::mock_dbus(HashMap::from([(
let dbus = common::mock_dbus(HashMap::from([(
getuid(),
vec![CLOUDFLARE_PARENTALCONTROL_ADDRS.clone()],
)]));
const HOSTNAME: &str = "pornhub.com";
let system_addr = common::resolve_with_system(libc::AF_INET, HOSTNAME)?;
let our_addr = common::resolve_with_module(libc::AF_INET, HOSTNAME)?;
assert_ne!(system_addr, our_addr);
assert_eq!(our_addr, IpAddr::V4(Ipv4Addr::UNSPECIFIED));
timeout(Duration::from_secs(1), dbus).await??
})
}
#[test]
fn adultsite_is_restricted_ipv6() -> Result<()> {
common::setup()?;
const HOSTNAME: &str = "nudity.testcategory.com";
tokio::runtime::Runtime::new().unwrap().block_on(async {
let dbus = common::mock_dbus(HashMap::from([(
getuid(),
vec![CLOUDFLARE_PARENTALCONTROL_ADDRS.clone()],
)]));
let system_addr = common::resolve_with_system(libc::AF_INET6, HOSTNAME)?;
let our_addr = common::resolve_with_module(libc::AF_INET6, HOSTNAME)?;
assert_ne!(system_addr, our_addr);
assert_eq!(our_addr, IpAddr::V6(Ipv6Addr::UNSPECIFIED));
//timeout(Duration::from_secs(1), dbus).await??
Ok(())
timeout(Duration::from_secs(1), dbus).await??
})
}
@ -155,20 +183,17 @@ fork_test! {
fn privileged_user_bypasses_restrictions() -> Result<()> {
common::setup()?;
const HOSTNAME: &str = "nudity.testcategory.com";
tokio::runtime::Runtime::new().unwrap().block_on(async {
let _dbus = common::mock_dbus(HashMap::from([(getuid(), vec![ /* no restriction */])]));
const HOSTNAME: &str = "pornhub.com";
for family in [libc::AF_INET, libc::AF_INET6] {
let dbus = common::mock_dbus(HashMap::from([(getuid(), vec![ /* no restriction */])]));
let system_addr = common::resolve_with_system(family, HOSTNAME)?;
let our_addr = common::resolve_with_module(family, HOSTNAME)?;
assert_eq!(system_addr, our_addr);
timeout(Duration::from_secs(1), dbus).await???
}
//timeout(Duration::from_secs(1), dbus).await??
Ok(())
})
}
}